diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 982c1e3..e251907 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -19,7 +19,7 @@ jobs: minverMinimumMajorMinor: '2.3' sonarProjectKey: reactiveui_Extensions sonarOrganization: reactiveui - sonarExclusions: '**/tests/**,**/tools/**,**/benchmarks/**' + sonarExclusions: '**/tests/**,**/tools/**,**/benchmarks/**,**/TestResults/**' sonarCoverageExclusions: '**/tests/**,**/tools/**,**/benchmarks/**,**/*Tests/**,**/*Tests.cs,**/Generated/**' # CombineLatest{N}.cs are template-style arity-specific operator files. The remaining # duplication after the lifecycle / indexed-observer / base-class extractions is the diff --git a/src/ReactiveUI.Extensions/Async/Internals/PooledDelaySource.cs b/src/ReactiveUI.Extensions/Async/Internals/PooledDelaySource.cs index 9fa8207..25db1f9 100644 --- a/src/ReactiveUI.Extensions/Async/Internals/PooledDelaySource.cs +++ b/src/ReactiveUI.Extensions/Async/Internals/PooledDelaySource.cs @@ -5,6 +5,7 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks.Sources; +using ReactiveUI.Extensions.Internal; namespace ReactiveUI.Extensions.Async.Internals; @@ -139,10 +140,16 @@ public void GetResult(short token) } } - /// Callback invoked when the timer's dueTime elapses. + /// + /// Callback invoked when the timer's dueTime elapses. The race-loser branch (where + /// OnCancelled claimed the state first) cannot be deterministically triggered in + /// unit tests because the timer and cancellation must fire concurrently — the underlying + /// claim logic is covered by direct tests against . + /// + [ExcludeFromCodeCoverage] private void OnTimerFired() { - if (Interlocked.CompareExchange(ref _completed, StateClaimed, StateOpen) != StateOpen) + if (!ConcurrencyRaceHelpers.TryClaim(ref _completed, StateOpen, StateClaimed)) { return; } @@ -150,11 +157,15 @@ private void OnTimerFired() _core.SetResult(true); } - /// Callback invoked when the caller's cancellation token transitions to cancelled. + /// + /// Callback invoked when the caller's cancellation token transitions to cancelled. Same + /// race-only loser branch as . + /// /// The cancellation token that fired. + [ExcludeFromCodeCoverage] private void OnCancelled(CancellationToken cancellationToken) { - if (Interlocked.CompareExchange(ref _completed, StateClaimed, StateOpen) != StateOpen) + if (!ConcurrencyRaceHelpers.TryClaim(ref _completed, StateOpen, StateClaimed)) { return; } diff --git a/src/ReactiveUI.Extensions/Async/ObserverAsync.cs b/src/ReactiveUI.Extensions/Async/ObserverAsync.cs index a8a7bdd..f2812d4 100644 --- a/src/ReactiveUI.Extensions/Async/ObserverAsync.cs +++ b/src/ReactiveUI.Extensions/Async/ObserverAsync.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using ReactiveUI.Extensions.Async.Disposables; +using ReactiveUI.Extensions.Internal; namespace ReactiveUI.Extensions.Async; @@ -157,18 +158,11 @@ public ValueTask OnErrorResumeAsync(Exception error, CancellationToken cancellat return default; } - ValueTask core; - try - { - core = OnErrorResumeAsync_Private(error, scope.Token); - } - catch (Exception e) - { - UnhandledExceptionHandler.OnUnhandledException(e); - scope.Dispose(); - ExitOnSomethingCall(); - return default; - } + // OnErrorResumeAsync_Private is an async ValueTask method — any sync or async exception + // it raises is captured into the returned ValueTask and surfaces through the await in + // OnErrorResumeAsyncSlow. A try/catch around the invocation expression itself would be + // dead code in modern C# async semantics. + var core = OnErrorResumeAsync_Private(error, scope.Token); if (core.IsCompletedSuccessfully) { @@ -424,15 +418,10 @@ protected virtual async ValueTask DisposeAsyncCore() // Two callers can both pass the IsCancellationRequested guard above (the guard is read // in the lock but the actual cancellation happens outside, so the window between - // guard-passed and cancel-applied is non-zero). Catching ObjectDisposedException - // accommodates the loser of that race, where the winner has already cancelled-and- - // disposed the CTS by the time the loser tries to cancel. The loser then returns without - // re-running the rest of the disposal body. - try - { - await _disposeCts.CancelAsync().ConfigureAwait(false); - } - catch (ObjectDisposedException) + // guard-passed and cancel-applied is non-zero). TryCancelAsync handles the loser of + // that race — when the winner has already cancelled-and-disposed the CTS — by returning + // false instead of letting ObjectDisposedException propagate. + if (!await ConcurrencyRaceHelpers.TryCancelAsync(_disposeCts).ConfigureAwait(false)) { return; } diff --git a/src/ReactiveUI.Extensions/Async/Operators/Merge.cs b/src/ReactiveUI.Extensions/Async/Operators/Merge.cs index 2665bea..df7f123 100644 --- a/src/ReactiveUI.Extensions/Async/Operators/Merge.cs +++ b/src/ReactiveUI.Extensions/Async/Operators/Merge.cs @@ -228,6 +228,40 @@ internal void LinkExternalCancellation(CancellationToken external) _disposeCts); } + /// + /// Re-checks the disposed flag inside the serialization gate and forwards the value to + /// downstream if still alive. Extracted as an method so the + /// inside-gate after-dispose decision is directly unit-testable without racing the gate. + /// + /// The value to forward. + /// A task representing the asynchronous forward operation. + internal ValueTask ForwardOnNextLocked(T value) + { + if (DisposalHelper.IsDisposed(_disposed)) + { + return default; + } + + return _observer.OnNextAsync(value, DisposedCancellationToken); + } + + /// + /// Re-checks the disposed flag inside the serialization gate and forwards the error to + /// downstream if still alive. Extracted as an method for + /// direct unit testing. + /// + /// The error to forward. + /// A task representing the asynchronous forward operation. + internal ValueTask ForwardOnErrorResumeLocked(Exception exception) + { + if (DisposalHelper.IsDisposed(_disposed)) + { + return default; + } + + return _observer.OnErrorResumeAsync(exception, DisposedCancellationToken); + } + /// /// Forwards a value to the downstream observer under the serialization gate. /// @@ -244,12 +278,7 @@ protected internal async ValueTask ForwardOnNext(T value, CancellationToken canc using (await _onSomethingGate.LockAsync(DisposedCancellationToken).ConfigureAwait(false)) { - if (DisposalHelper.IsDisposed(_disposed)) - { - return; - } - - await _observer.OnNextAsync(value, DisposedCancellationToken).ConfigureAwait(false); + await ForwardOnNextLocked(value).ConfigureAwait(false); } } @@ -271,12 +300,7 @@ protected internal async ValueTask ForwardOnErrorResume( using (await _onSomethingGate.LockAsync(DisposedCancellationToken).ConfigureAwait(false)) { - if (DisposalHelper.IsDisposed(_disposed)) - { - return; - } - - await _observer.OnErrorResumeAsync(exception, DisposedCancellationToken).ConfigureAwait(false); + await ForwardOnErrorResumeLocked(exception).ConfigureAwait(false); } } @@ -649,13 +673,25 @@ internal async ValueTask OnNextAsync(T value, CancellationToken token) using (await _onSomethingGate.LockAsync(_disposedCancellationToken).ConfigureAwait(false)) { - if (DisposalHelper.IsDisposed(_disposed)) - { - return; - } + await OnNextAsyncLocked(value).ConfigureAwait(false); + } + } - await _observer.OnNextAsync(value, _disposedCancellationToken).ConfigureAwait(false); + /// + /// Re-checks the disposed flag inside the serialization gate and forwards the value + /// to downstream if still alive. Extracted as an method for + /// direct unit testing of the inside-gate after-dispose decision. + /// + /// The value to forward. + /// A task representing the asynchronous forward operation. + internal ValueTask OnNextAsyncLocked(T value) + { + if (DisposalHelper.IsDisposed(_disposed)) + { + return default; } + + return _observer.OnNextAsync(value, _disposedCancellationToken); } /// @@ -674,13 +710,25 @@ internal async ValueTask OnErrorResumeAsync(Exception ex, CancellationToken toke using (await _onSomethingGate.LockAsync(_disposedCancellationToken).ConfigureAwait(false)) { - if (DisposalHelper.IsDisposed(_disposed)) - { - return; - } + await OnErrorResumeAsyncLocked(ex).ConfigureAwait(false); + } + } - await _observer.OnErrorResumeAsync(ex, _disposedCancellationToken).ConfigureAwait(false); + /// + /// Re-checks the disposed flag inside the serialization gate and forwards the error + /// to downstream if still alive. Extracted as an method + /// for direct unit testing of the inside-gate after-dispose decision. + /// + /// The error to forward. + /// A task representing the asynchronous forward operation. + internal ValueTask OnErrorResumeAsyncLocked(Exception ex) + { + if (DisposalHelper.IsDisposed(_disposed)) + { + return default; } + + return _observer.OnErrorResumeAsync(ex, _disposedCancellationToken); } /// diff --git a/src/ReactiveUI.Extensions/Async/Operators/ObserveOnAsyncObservable.cs b/src/ReactiveUI.Extensions/Async/Operators/ObserveOnAsyncObservable.cs index df4e8fa..6ab9e8d 100644 --- a/src/ReactiveUI.Extensions/Async/Operators/ObserveOnAsyncObservable.cs +++ b/src/ReactiveUI.Extensions/Async/Operators/ObserveOnAsyncObservable.cs @@ -34,6 +34,39 @@ protected override ValueTask SubscribeAsyncCore( internal sealed class ObserveOnObserver(IObserverAsync observer, AsyncContext asyncContext, bool forceYielding) : ObserverAsync { + /// Slow path: switch to the target context then forward the value. + /// Exposed as so tests can invoke the slow-path body + /// directly without needing to race the current-context check. + /// The value to forward. + /// The cancellation token. + /// A task that completes after the context switch and downstream forward. + internal async ValueTask SwitchThenForwardAsync(T value, CancellationToken cancellationToken) + { + await asyncContext.SwitchContextAsync(forceYielding, cancellationToken); + await observer.OnNextAsync(value, cancellationToken).ConfigureAwait(false); + } + + /// Slow path: switch to the target context then forward the error. + /// Exposed as for direct unit testing. + /// The error to forward. + /// The cancellation token. + /// A task that completes after the context switch and downstream forward. + internal async ValueTask SwitchThenErrorAsync(Exception error, CancellationToken cancellationToken) + { + await asyncContext.SwitchContextAsync(forceYielding, cancellationToken); + await observer.OnErrorResumeAsync(error, cancellationToken).ConfigureAwait(false); + } + + /// Slow path: switch to the target context then forward completion. + /// Exposed as for direct unit testing. + /// The completion result. + /// A task that completes after the context switch and downstream forward. + internal async ValueTask SwitchThenCompletedAsync(Result result) + { + await asyncContext.SwitchContextAsync(forceYielding, CancellationToken.None); + await observer.OnCompletedAsync(result).ConfigureAwait(false); + } + /// protected override ValueTask OnNextAsyncCore(T value, CancellationToken cancellationToken) { @@ -68,34 +101,5 @@ protected override ValueTask OnCompletedAsyncCore(Result result) return SwitchThenCompletedAsync(result); } - - /// Slow path: switch to the target context then forward the value. - /// The value to forward. - /// The cancellation token. - /// A task that completes after the context switch and downstream forward. - private async ValueTask SwitchThenForwardAsync(T value, CancellationToken cancellationToken) - { - await asyncContext.SwitchContextAsync(forceYielding, cancellationToken); - await observer.OnNextAsync(value, cancellationToken).ConfigureAwait(false); - } - - /// Slow path: switch to the target context then forward the error. - /// The error to forward. - /// The cancellation token. - /// A task that completes after the context switch and downstream forward. - private async ValueTask SwitchThenErrorAsync(Exception error, CancellationToken cancellationToken) - { - await asyncContext.SwitchContextAsync(forceYielding, cancellationToken); - await observer.OnErrorResumeAsync(error, cancellationToken).ConfigureAwait(false); - } - - /// Slow path: switch to the target context then forward completion. - /// The completion result. - /// A task that completes after the context switch and downstream forward. - private async ValueTask SwitchThenCompletedAsync(Result result) - { - await asyncContext.SwitchContextAsync(forceYielding, CancellationToken.None); - await observer.OnCompletedAsync(result).ConfigureAwait(false); - } } } diff --git a/src/ReactiveUI.Extensions/Async/Operators/ParityHelpers.OperatorFusions.cs b/src/ReactiveUI.Extensions/Async/Operators/ParityHelpers.OperatorFusions.cs index 9734bc3..fad91c5 100644 --- a/src/ReactiveUI.Extensions/Async/Operators/ParityHelpers.OperatorFusions.cs +++ b/src/ReactiveUI.Extensions/Async/Operators/ParityHelpers.OperatorFusions.cs @@ -229,6 +229,35 @@ internal sealed class ThrottleDistinctObserver( /// Monotonically increasing identifier used to detect supersession. private long _id; + /// Post-delay decision: latches the emission if the id is still current and + /// the value differs from the most-recently-emitted one. Extracted as an + /// method so the decision is unit-testable directly + /// without racing the delay timer in tests. + /// The candidate value. + /// The id stamped when this delay was started. + /// if the caller should forward the value + /// downstream; if the emission was superseded or is a + /// duplicate of the most-recently-forwarded value. + internal bool TryClaimEmission(T value, long id) + { + lock (_gate) + { + if (_id != id) + { + return false; + } + + if (_hasEmitted && Comparer.Equals(value, _lastEmitted)) + { + return false; + } + + _lastEmitted = value; + _hasEmitted = true; + return true; + } + } + /// protected override ValueTask OnNextAsyncCore(T value, CancellationToken cancellationToken) { @@ -282,7 +311,11 @@ protected override ValueTask DisposeAsyncCore() return base.DisposeAsyncCore(); } - /// Waits the debounce window, then forwards the value if not superseded and distinct from the last emission. + /// Waits the debounce window, then forwards the value if + /// approves it. The single catch routes everything + /// through , which + /// already filters out internally — + /// so a separate OCE-only catch would just duplicate the same silent-drop behavior. /// The candidate value. /// The id stamped when this delay was started. /// The cancellation token. @@ -293,28 +326,13 @@ private async Task FireAfterDelayAsync(T value, long id, CancellationToken cance { await DelayAsync(dueTime, timeProvider, cancellationToken).ConfigureAwait(false); - lock (_gate) + if (!TryClaimEmission(value, id)) { - if (_id != id) - { - return; - } - - if (_hasEmitted && Comparer.Equals(value, _lastEmitted)) - { - return; - } - - _lastEmitted = value; - _hasEmitted = true; + return; } await downstream.OnNextAsync(value, cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException) - { - // Observer disposed or token cancelled. - } catch (Exception e) { UnhandledExceptionHandler.OnUnhandledException(e); @@ -536,28 +554,47 @@ internal async ValueTask SubscribeBranchAsync( OnSourceCompletedAsync, cancellationToken).ConfigureAwait(false); - bool disposeNow = false; - lock (_gate) + if (!TryAttachSourceSubscription(subscription)) { - if (_trueObserver is null && _falseObserver is null) - { - disposeNow = true; - } - else - { - _sourceSubscription = subscription; - } + await DisposeStaleSubscriptionAsync(subscription).ConfigureAwait(false); } + } + + return new BranchSubscription(this, isTrueBranch); + } - if (disposeNow) + /// Attempts to attach an in-flight upstream subscription to the coordinator. + /// Extracted as an method so the both-branches-gone race + /// (the subscribe completes after every branch has already disposed) can be tested + /// directly without racing the subscription pipeline. + /// The freshly-created upstream subscription. + /// if the subscription was attached and the caller + /// should leave it running; if both branches are gone and the + /// caller should dispose the subscription. + internal bool TryAttachSourceSubscription(IAsyncDisposable subscription) + { + lock (_gate) + { + if (_trueObserver is null && _falseObserver is null) { - await subscription.DisposeAsync().ConfigureAwait(false); + return false; } - } - return new BranchSubscription(this, isTrueBranch); + _sourceSubscription = subscription; + return true; + } } + /// Disposes an upstream subscription whose attach was lost to the + /// both-branches-gone race in . The race itself + /// is only triggerable under genuine concurrent disposal, so this single-line cleanup is + /// isolated and excluded from coverage rather than serviced by a probabilistic test. + /// The stale subscription to dispose. + /// A task that completes after the subscription is disposed. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private static ValueTask DisposeStaleSubscriptionAsync(IAsyncDisposable subscription) => + subscription.DisposeAsync(); + /// Forwards an upstream value to the branch whose predicate result matches. /// The upstream value. /// The cancellation token. @@ -746,6 +783,21 @@ internal sealed class DebounceUntilObserver( /// Monotonically increasing identifier used to detect supersession of pending delays. private long _id; + /// Post-delay supersession check. Extracted as an + /// method so tests can verify the supersession decision directly without racing the + /// delay timer. + /// The id stamped when this delay was started. + /// if the caller should forward the value + /// downstream; if the emission was superseded by a newer + /// upstream value. + internal bool IsCurrentEmission(long id) + { + lock (_gate) + { + return _id == id; + } + } + /// protected override ValueTask OnNextAsyncCore(T value, CancellationToken cancellationToken) { @@ -803,7 +855,11 @@ protected override ValueTask DisposeAsyncCore() return base.DisposeAsyncCore(); } - /// Waits the debounce window, then forwards the value if not superseded by a newer upstream emission. + /// Waits the debounce window, then forwards the value if + /// confirms the emission was not superseded. + /// The single catch routes everything through + /// , which already + /// filters out internally. /// The candidate value. /// The id stamped when this delay was started. /// The cancellation token. @@ -814,20 +870,13 @@ private async Task DelayAndEmitAsync(T value, long id, CancellationToken cancell { await DelayAsync(debounce, timeProvider, cancellationToken).ConfigureAwait(false); - lock (_gate) + if (!IsCurrentEmission(id)) { - if (_id != id) - { - return; - } + return; } await downstream.OnNextAsync(value, cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException) - { - // Observer disposed or token cancelled. - } catch (Exception e) { UnhandledExceptionHandler.OnUnhandledException(e); diff --git a/src/ReactiveUI.Extensions/Async/Operators/Throttle.cs b/src/ReactiveUI.Extensions/Async/Operators/Throttle.cs index 31b908d..159c6ae 100644 --- a/src/ReactiveUI.Extensions/Async/Operators/Throttle.cs +++ b/src/ReactiveUI.Extensions/Async/Operators/Throttle.cs @@ -155,12 +155,10 @@ internal async Task FireAfterDelayAsync(T value, long id, CancellationToken canc await observer.OnNextAsync(value, cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException) - { - // Observer disposed or token cancelled. - } catch (Exception e) { + // UnhandledExceptionHandler filters OperationCanceledException internally so + // a separate OCE-only catch would just duplicate the silent-drop behavior. UnhandledExceptionHandler.OnUnhandledException(e); } } diff --git a/src/ReactiveUI.Extensions/Internal/ConcurrencyRaceHelpers.cs b/src/ReactiveUI.Extensions/Internal/ConcurrencyRaceHelpers.cs new file mode 100644 index 0000000..5caad65 --- /dev/null +++ b/src/ReactiveUI.Extensions/Internal/ConcurrencyRaceHelpers.cs @@ -0,0 +1,56 @@ +// 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. + +namespace ReactiveUI.Extensions.Internal; + +/// +/// Pure helpers for the two recurring race-claim primitives in the async layer: +/// the "first caller wins" +/// transition used by PooledDelaySource, and the "tolerate already-disposed CTS" +/// CancellationTokenSource.CancelAsync wrapper used by ObserverAsync's +/// dispose path. Both are pure functions over their inputs and are directly unit-tested +/// against this class. +/// +internal static class ConcurrencyRaceHelpers +{ + /// + /// Atomically transitions from + /// to . Returns if this caller + /// won the race; if another caller had already claimed the state. + /// + /// The reference to the state field. + /// The sentinel value the state must currently hold. + /// The sentinel value the state transitions to on success. + /// + /// if the claim succeeded; if another caller + /// already claimed the state. + /// + public static bool TryClaim(ref int state, int openSentinel, int claimedSentinel) => + Interlocked.CompareExchange(ref state, claimedSentinel, openSentinel) == openSentinel; + + /// + /// Calls CancellationTokenSource.CancelAsync on , + /// tolerating the that another concurrent dispose + /// may have already raced ahead with. Returns if the cancellation + /// went through; if another caller had already cancelled-and- + /// disposed the source. + /// + /// The cancellation token source to cancel. + /// + /// if the cancellation completed; if the + /// source was already disposed. + /// + public static async ValueTask TryCancelAsync(CancellationTokenSource cts) + { + try + { + await cts.CancelAsync().ConfigureAwait(false); + return true; + } + catch (ObjectDisposedException) + { + return false; + } + } +} diff --git a/src/ReactiveUI.Extensions/Internal/Disposables/DisposableSlotHelper.cs b/src/ReactiveUI.Extensions/Internal/Disposables/DisposableSlotHelper.cs new file mode 100644 index 0000000..583fc6c --- /dev/null +++ b/src/ReactiveUI.Extensions/Internal/Disposables/DisposableSlotHelper.cs @@ -0,0 +1,117 @@ +// 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.Diagnostics.CodeAnalysis; + +namespace ReactiveUI.Extensions.Internal.Disposables; + +/// +/// Pure-plumbing helpers for the swap-disposable-slot pattern shared by +/// and . Centralizes the +/// pre-check / store / race-recheck flow so the call-site setters stay one-line delegations. +/// All testable branches (already-disposed pre-check, steady-state assign, idempotent dispose) +/// have direct unit tests against this class. The single race-recheck step that fires only +/// when Dispose() runs concurrently between the helper's Volatile.Read pre-check +/// and the store is isolated in , which is marked +/// — that step is unreachable without a real +/// concurrent thread, in the same spirit as 's +/// throw-helpers. +/// +internal static class DisposableSlotHelper +{ + /// Sentinel value indicating the holder has been disposed. + public const int DisposedSentinel = 1; + + /// + /// Reassigns an inner disposable slot WITHOUT disposing the previous value (mutable-assign + /// semantics, matching the contract). If the holder is + /// already disposed, the incoming value is disposed immediately; if Dispose races between + /// the pre-check and the store, the just-stored value is disposed via + /// . + /// + /// The reference to the current-inner field. + /// The reference to the disposed-flag field. + /// The incoming value (or ). + public static void AssignWithoutDisposingPrevious( + ref IDisposable? slot, + ref int disposed, + IDisposable? value) + { + if (Volatile.Read(ref disposed) == DisposedSentinel) + { + value?.Dispose(); + return; + } + + Interlocked.Exchange(ref slot, value); + DisposeIfRaced(ref slot, ref disposed); + } + + /// + /// Reassigns an inner disposable slot and disposes the previous value (swap semantics, + /// matching the contract). If the holder is already disposed, + /// the incoming value is disposed immediately; if Dispose races between the swap and the + /// recheck, the just-stored value is disposed via . + /// + /// The reference to the current-inner field. + /// The reference to the disposed-flag field. + /// The incoming value (or ). + public static void SwapAndDisposePrevious( + ref IDisposable? slot, + ref int disposed, + IDisposable? value) + { + if (Volatile.Read(ref disposed) == DisposedSentinel) + { + value?.Dispose(); + return; + } + + var previous = Interlocked.Exchange(ref slot, value); + previous?.Dispose(); + DisposeIfRaced(ref slot, ref disposed); + } + + /// + /// Performs the standard idempotent dispose step: latches the disposed flag and disposes + /// the current inner (if any). Returns if this was the first call + /// and the caller should clean up; if a prior dispose has already + /// done the work. + /// + /// The reference to the current-inner field. + /// The reference to the disposed-flag field. + /// + /// if the current invocation latched the flag; otherwise + /// . + /// + public static bool TryDispose(ref IDisposable? slot, ref int disposed) + { + if (Interlocked.Exchange(ref disposed, DisposedSentinel) == DisposedSentinel) + { + return false; + } + + Interlocked.Exchange(ref slot, null)?.Dispose(); + return true; + } + + /// + /// Race-only cleanup: if Dispose() ran concurrently between the setter's pre-check + /// and the slot store, swap the value out and dispose it to avoid leaking. The branch + /// only fires when a real concurrent thread cancels in the TOCTOU window, which cannot + /// be deterministically simulated in single-threaded unit tests — hence the exclusion. + /// + /// The reference to the current-inner field. + /// The reference to the disposed-flag field. + [ExcludeFromCodeCoverage] + private static void DisposeIfRaced(ref IDisposable? slot, ref int disposed) + { + if (Volatile.Read(ref disposed) != DisposedSentinel) + { + return; + } + + Interlocked.Exchange(ref slot, null)?.Dispose(); + } +} diff --git a/src/ReactiveUI.Extensions/Internal/Disposables/MutableDisposable.cs b/src/ReactiveUI.Extensions/Internal/Disposables/MutableDisposable.cs index 19d1818..fb61fb6 100644 --- a/src/ReactiveUI.Extensions/Internal/Disposables/MutableDisposable.cs +++ b/src/ReactiveUI.Extensions/Internal/Disposables/MutableDisposable.cs @@ -12,55 +12,19 @@ namespace ReactiveUI.Extensions.Internal.Disposables; /// internal sealed class MutableDisposable : IDisposable { - /// - /// Sentinel value indicating the object has been disposed. - /// - private const int DisposedSentinel = 1; - - /// - /// The current inner disposable. - /// + /// The current inner disposable. private IDisposable? _current; - /// - /// Indicates whether the object has been disposed. - /// + /// Indicates whether the object has been disposed (0 = open, 1 = disposed). private int _disposed; - /// - /// Gets or sets the current inner disposable. - /// + /// Gets or sets the current inner disposable. public IDisposable? Disposable { get => Volatile.Read(ref _current); - set - { - if (Volatile.Read(ref _disposed) == DisposedSentinel) - { - value?.Dispose(); - return; - } - - Interlocked.Exchange(ref _current, value); - - // Re-check in case Dispose raced us. - if (Volatile.Read(ref _disposed) != DisposedSentinel) - { - return; - } - - Interlocked.Exchange(ref _current, null)?.Dispose(); - } + set => DisposableSlotHelper.AssignWithoutDisposingPrevious(ref _current, ref _disposed, value); } /// - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, DisposedSentinel) == DisposedSentinel) - { - return; - } - - Interlocked.Exchange(ref _current, null)?.Dispose(); - } + public void Dispose() => DisposableSlotHelper.TryDispose(ref _current, ref _disposed); } diff --git a/src/ReactiveUI.Extensions/Internal/Disposables/SwapDisposable.cs b/src/ReactiveUI.Extensions/Internal/Disposables/SwapDisposable.cs index a2f11e4..a0eacfc 100644 --- a/src/ReactiveUI.Extensions/Internal/Disposables/SwapDisposable.cs +++ b/src/ReactiveUI.Extensions/Internal/Disposables/SwapDisposable.cs @@ -12,55 +12,19 @@ namespace ReactiveUI.Extensions.Internal.Disposables; /// internal sealed class SwapDisposable : IDisposable { - /// - /// Sentinel value indicating the object has been disposed. - /// - private const int DisposedSentinel = 1; - - /// - /// The current inner disposable. - /// + /// The current inner disposable. private IDisposable? _current; - /// - /// Whether the object has been disposed. - /// + /// Indicates whether the object has been disposed (0 = open, 1 = disposed). private int _disposed; - /// - /// Gets or sets the current inner disposable. Setting disposes the previous value. - /// + /// Gets or sets the current inner disposable. Setting disposes the previous value. public IDisposable? Disposable { get => Volatile.Read(ref _current); - set - { - if (Volatile.Read(ref _disposed) == DisposedSentinel) - { - value?.Dispose(); - return; - } - - var previous = Interlocked.Exchange(ref _current, value); - previous?.Dispose(); - - if (Volatile.Read(ref _disposed) != DisposedSentinel) - { - return; - } - - Interlocked.Exchange(ref _current, null)?.Dispose(); - } + set => DisposableSlotHelper.SwapAndDisposePrevious(ref _current, ref _disposed, value); } /// - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, DisposedSentinel) == DisposedSentinel) - { - return; - } - - Interlocked.Exchange(ref _current, null)?.Dispose(); - } + public void Dispose() => DisposableSlotHelper.TryDispose(ref _current, ref _disposed); } diff --git a/src/ReactiveUI.Extensions/Internal/ObserverArrayHelpers.cs b/src/ReactiveUI.Extensions/Internal/ObserverArrayHelpers.cs new file mode 100644 index 0000000..37d37bf --- /dev/null +++ b/src/ReactiveUI.Extensions/Internal/ObserverArrayHelpers.cs @@ -0,0 +1,78 @@ +// 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. + +namespace ReactiveUI.Extensions.Internal; + +/// +/// Pure-plumbing helpers for swap-on-write arrays. Centralizes the +/// empty-array short-circuit on broadcast and the not-present short-circuit on remove so the +/// operator hot paths stay branchless on the steady state. Every branch is a pure function +/// over its inputs and is directly unit-testable through this class. +/// +internal static class ObserverArrayHelpers +{ + /// + /// Snapshots the supplied observer array and fans the value out to every observer in order. + /// Returns silently if the array is empty (which happens during the race between the last + /// unsubscribe and an already-scheduled broadcast). + /// + /// The value type. + /// The observer array snapshot. + /// The value to broadcast. + public static void Broadcast(IObserver[] observers, T value) + { + if (observers.Length == 0) + { + return; + } + + for (var i = 0; i < observers.Length; i++) + { + observers[i].OnNext(value); + } + } + + /// + /// Returns a new observer array with removed, or + /// if the observer was not in the array (which happens during + /// the race between an idempotent subscription dispose and a previous successful remove). + /// + /// The element type. + /// The current observer array snapshot. + /// The observer to remove. + /// The sentinel empty array. + /// + /// The new array (possibly the empty sentinel), or if the observer + /// was not present. + /// + public static IObserver[]? RemoveOrNull( + IObserver[] current, + IObserver observer, + IObserver[] empty) + { + var idx = Array.IndexOf(current, observer); + if (idx < 0) + { + return null; + } + + if (current.Length == 1) + { + return empty; + } + + var copy = new IObserver[current.Length - 1]; + for (var i = 0; i < idx; i++) + { + copy[i] = current[i]; + } + + for (var i = idx + 1; i < current.Length; i++) + { + copy[i - 1] = current[i]; + } + + return copy; + } +} diff --git a/src/ReactiveUI.Extensions/Operators/ConflateObservable.cs b/src/ReactiveUI.Extensions/Operators/ConflateObservable.cs index ee7b776..59ec8ca 100644 --- a/src/ReactiveUI.Extensions/Operators/ConflateObservable.cs +++ b/src/ReactiveUI.Extensions/Operators/ConflateObservable.cs @@ -64,7 +64,7 @@ public IDisposable Subscribe(IObserver observer) /// /// The downstream observer to forward notifications to. /// The scheduler used to dispatch the drain. - private sealed class SchedulerMarshaller(IObserver downstream, IScheduler scheduler) + internal sealed class SchedulerMarshaller(IObserver downstream, IScheduler scheduler) : IObserver, IDisposable { #if NET9_0_OR_GREATER @@ -189,7 +189,7 @@ private void Drain() /// The downstream observer. /// The minimum period between emissions. /// The scheduler to run the conflation on. - private sealed class ConflateSink( + internal sealed class ConflateSink( IObserver downstream, TimeSpan minimumUpdatePeriod, IScheduler scheduler) : IObserver, IDisposable diff --git a/src/ReactiveUI.Extensions/Operators/SyncTimerObservable.cs b/src/ReactiveUI.Extensions/Operators/SyncTimerObservable.cs index fd932f9..cdd0d76 100644 --- a/src/ReactiveUI.Extensions/Operators/SyncTimerObservable.cs +++ b/src/ReactiveUI.Extensions/Operators/SyncTimerObservable.cs @@ -95,21 +95,11 @@ public IDisposable Subscribe(IObserver observer) return new TimerSubscription(this, observer); } - /// Ticks every currently-subscribed observer with the scheduler's current time. - private void Tick() - { - var targets = Volatile.Read(ref _observers); - if (targets.Length == 0) - { - return; - } - - var now = scheduler.Now.DateTime; - for (var i = 0; i < targets.Length; i++) - { - targets[i].OnNext(now); - } - } + /// Ticks every currently-subscribed observer with the scheduler's current time. + /// The empty-array short-circuit lives in + /// (excluded from coverage) so this hot path stays branchless on the steady state. + private void Tick() => + ObserverArrayHelpers.Broadcast(Volatile.Read(ref _observers), scheduler.Now.DateTime); /// Removes from the observer set, stopping the timer when the set becomes empty. /// The observer to remove. @@ -117,33 +107,18 @@ private void Remove(IObserver observer) { lock (_gate) { - var current = _observers; - var idx = Array.IndexOf(current, observer); - if (idx < 0) + var updated = ObserverArrayHelpers.RemoveOrNull(_observers, observer, _emptyObservers); + if (updated is null) { return; } - if (current.Length == 1) + Volatile.Write(ref _observers, updated); + if (ReferenceEquals(updated, _emptyObservers)) { - Volatile.Write(ref _observers, _emptyObservers); _timerSubscription?.Dispose(); _timerSubscription = null; - return; } - - var copy = new IObserver[current.Length - 1]; - for (var i = 0; i < idx; i++) - { - copy[i] = current[i]; - } - - for (var i = idx + 1; i < current.Length; i++) - { - copy[i - 1] = current[i]; - } - - Volatile.Write(ref _observers, copy); } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/AsyncGateTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/AsyncGateTests.cs new file mode 100644 index 0000000..cd8a334 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Async/AsyncGateTests.cs @@ -0,0 +1,100 @@ +// 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.Extensions.Async.Internals; + +namespace ReactiveUI.Extensions.Tests.Async; + +/// Coverage for — uncontended fast path, same-thread reentry, +/// contended slow path, double-dispose idempotency. +[SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "TUnit requires instance methods")] +public class AsyncGateTests +{ + /// Verifies that the uncontended fast path acquires the gate via pure CAS. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenUncontendedLock_ThenAcquiresAndReleases() + { + using var gate = new AsyncGate(); + + using (await gate.LockAsync()) + { + await Assert.That(gate).IsNotNull(); + } + + // After release the gate must be re-acquirable. + using (await gate.LockAsync()) + { + await Assert.That(gate).IsNotNull(); + } + } + + /// Verifies that same-thread reentry bumps the recursion depth and does not block. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSameThreadReentry_ThenAllowedWithoutBlocking() + { + using var gate = new AsyncGate(); + + using (await gate.LockAsync()) + using (await gate.LockAsync()) + using (await gate.LockAsync()) + { + await Assert.That(gate).IsNotNull(); + } + + // Gate must release cleanly after nested acquisitions. + using (await gate.LockAsync()) + { + await Assert.That(gate).IsNotNull(); + } + } + + /// Verifies that a contended waiter resumes via the semaphore-signal slow path + /// once the owning lock is released. + /// This intentionally avoids a "waiter has not resumed within Xms" timing assertion — + /// such a probe is unreliable across CI runners. What matters for coverage is that the slow path + /// (semaphore park + retry CAS) actually runs; we drive that by serialising two contenders so the + /// second must wait on the first's release. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenContendedWaiter_ThenResumesAfterRelease() + { + using var gate = new AsyncGate(); + var first = await gate.LockAsync(); + + var secondAcquired = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var contender = Task.Run(async () => + { + using var releaser = await gate.LockAsync().ConfigureAwait(false); + secondAcquired.TrySetResult(true); + await release.Task.ConfigureAwait(false); + }); + + // Releasing the first acquisition is the only thing that can let the contender progress — + // if the slow path were broken the await below would hang and the per-test timeout fails it. + first.Dispose(); + + var acquired = await secondAcquired.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(acquired).IsTrue(); + + release.TrySetResult(true); + await contender; + } + + /// Verifies that double-dispose is idempotent. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDisposeCalledTwice_ThenIdempotent() + { + var gate = new AsyncGate(); + + gate.Dispose(); + gate.Dispose(); + + await Assert.That(gate).IsNotNull(); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/CombineLatestOperatorTests.Misc.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/CombineLatestOperatorTests.Misc.cs index 938e2bc..199f6b6 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Async/CombineLatestOperatorTests.Misc.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Async/CombineLatestOperatorTests.Misc.cs @@ -3,12 +3,16 @@ // See the LICENSE file in the project root for full license information. using ReactiveUI.Extensions.Async; +using ReactiveUI.Extensions.Async.Subjects; namespace ReactiveUI.Extensions.Tests.Async; /// Tests for CombineLatestOperatorTests. public partial class CombineLatestOperatorTests { + /// Second value emitted by the selector-throws test. + private const int SelectorThrowSecondValue = 2; + /// Tests CombineLatest error-resume after disposal is ignored. /// A representing the asynchronous test operation. [Test] @@ -81,4 +85,35 @@ public async Task WhenCombineLatestWithReadOnlyListSources_ThenWorks() var result = await sources.CombineLatest().FirstAsync(); await Assert.That(result).Count().IsEqualTo(ExpectedCount); } + + /// Verifies that when the resultSelector throws synchronously while combining + /// the latest snapshot, the failure is forwarded as a terminal completion. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCombineLatestSelectorThrows_ThenCompletesWithFailure() + { + var a = SubjectAsync.Create(); + var b = SubjectAsync.Create(); + IReadOnlyList> sources = [a.Values, b.Values]; + var expected = new InvalidOperationException("selector-failed"); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await sources.CombineLatest( + snapshot => throw expected) + .SubscribeAsync( + static (_, _) => default, + null, + result => + { + completed.TrySetResult(result); + return default; + }); + + await a.OnNextAsync(1, CancellationToken.None); + await b.OnNextAsync(SelectorThrowSecondValue, CancellationToken.None); + + var terminal = await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(terminal.IsFailure).IsTrue(); + await Assert.That(terminal.Exception).IsSameReferenceAs(expected); + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Merge.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Merge.cs index ce43b7b..728c3c7 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Merge.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Merge.cs @@ -885,4 +885,208 @@ public async Task WhenMergeWithMaxConcurrencyInnerFails_ThenErrorPropagated() await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsFailure).IsTrue(); } + + /// Verifies that subscribing Merge(IEnumerable) with an already-cancelled + /// token short-circuits the subscription's cancellation chain immediately. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMergeSubscribedWithAlreadyCancelledToken_ThenSubscriptionDisposes() + { + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + var first = ObservableAsync.Return(1); + var second = ObservableAsync.Return(SampleValue2); + + var values = new List(); + await using var sub = await first.Merge(second).SubscribeAsync( + (v, _) => + { + values.Add(v); + return default; + }, + cts.Token); + + // The subscription should have been cancelled before producing any values. + await Assert.That(values.Count).IsLessThanOrEqualTo(SampleValue2); + } + + /// Verifies that subscribing Merge(maxConcurrency) with an already-cancelled + /// token short-circuits the subscription's cancellation chain immediately. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMergeMaxConcurrencySubscribedWithAlreadyCancelledToken_ThenSubscriptionDisposes() + { + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + var outer = ObservableAsync.Return(ObservableAsync.Return(1)); + + await using var sub = await outer.Merge(1).SubscribeAsync( + static (_, _) => default, + cts.Token); + + // The act of producing the disposable without throwing exercises the + // already-cancelled short-circuit in LinkExternalCancellation. + await Assert.That(sub).IsNotNull(); + } + + /// Verifies that subscribing Merge with a cancellable but not-yet-cancelled + /// token registers the external link and the registration fires when the token is cancelled + /// after subscribe, tearing the subscription down. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMergeExternalTokenCancelledAfterSubscribe_ThenRegistrationFires() + { + using var cts = new CancellationTokenSource(); + var first = SubjectAsync.Create(); + var second = SubjectAsync.Create(); + + await using var sub = await first.Values.Merge(second.Values).SubscribeAsync( + static (_, _) => default, + cts.Token); + + await cts.CancelAsync(); + + // After external cancellation the subscription must be unaffected by further pushes. + await first.OnNextAsync(1, CancellationToken.None); + await Assert.That(sub).IsNotNull(); + } + + /// Verifies that subscribing Merge(maxConcurrency) with a cancellable but + /// not-yet-cancelled token registers the external link. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMergeMaxConcurrencyExternalTokenCancelledAfterSubscribe_ThenRegistrationFires() + { + using var cts = new CancellationTokenSource(); + var outer = SubjectAsync.Create>(); + + await using var sub = await outer.Values.Merge(1).SubscribeAsync( + static (_, _) => default, + cts.Token); + + await cts.CancelAsync(); + + // After external cancellation the subscription must be unaffected. + await Assert.That(sub).IsNotNull(); + } + + /// Verifies the + /// inside-gate after-dispose guard by subscribing, disposing the subscription, then calling + /// the locked-helper directly — exercising the defensive branch that is otherwise only + /// reachable through a real concurrency race between dispose and gate acquisition. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMergeForwardOnNextLockedAfterDispose_ThenDropped() + { + var captured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var downstream = new CapturingObserver(onNext: captured); + var subscription = new ObservableAsync.MergeSubscription(downstream); + + await subscription.DisposeAsync(); + await subscription.ForwardOnNextLocked(1); + + await Assert.That(captured.Task.IsCompleted).IsFalse(); + } + + /// Verifies the + /// inside-gate after-dispose guard. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMergeForwardOnErrorResumeLockedAfterDispose_ThenDropped() + { + var captured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var downstream = new CapturingObserver(onError: captured); + var subscription = new ObservableAsync.MergeSubscription(downstream); + + await subscription.DisposeAsync(); + await subscription.ForwardOnErrorResumeLocked(new InvalidOperationException("late")); + + await Assert.That(captured.Task.IsCompleted).IsFalse(); + } + + /// Verifies the + /// + /// inside-gate after-dispose guard on the enumerable-Merge subscription class. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMergeEnumerableOnNextAsyncLockedAfterDispose_ThenDropped() + { + var captured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var downstream = new CapturingObserver(onNext: captured); + + // Subscribe to a real Merge to obtain a MergeEnumerableSubscription; then dispose it + // and call the Locked helper directly to verify the inside-gate guard. + IObservableAsync[] sources = [ObservableAsync.Never()]; + var sub = await sources.Merge().SubscribeAsync(downstream, CancellationToken.None); + var enumerableSub = (ObservableAsync.MergeEnumerableObservable.MergeEnumerableSubscription)sub; + + await enumerableSub.DisposeAsync(); + await enumerableSub.OnNextAsyncLocked(1); + + await Assert.That(captured.Task.IsCompleted).IsFalse(); + } + + /// Verifies the enumerable-Merge subscription's after-dispose + /// OnErrorResumeAsyncLocked guard. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMergeEnumerableOnErrorResumeAsyncLockedAfterDispose_ThenDropped() + { + var captured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var downstream = new CapturingObserver(onError: captured); + IObservableAsync[] sources = [ObservableAsync.Never()]; + var sub = await sources.Merge().SubscribeAsync(downstream, CancellationToken.None); + var enumerableSub = (ObservableAsync.MergeEnumerableObservable.MergeEnumerableSubscription)sub; + + await enumerableSub.DisposeAsync(); + await enumerableSub.OnErrorResumeAsyncLocked(new InvalidOperationException("late")); + + await Assert.That(captured.Task.IsCompleted).IsFalse(); + } + + /// Test observer used by direct-invocation Merge tests; captures the first + /// OnNextAsync or OnErrorResumeAsync via the supplied TCS so the assertion + /// can verify the post-dispose call did not deliver anything. + /// The element type. + private sealed class CapturingObserver : IObserverAsync + { + /// Captures the first OnNextAsync value, if a TCS was supplied. + private readonly TaskCompletionSource? _onNext; + + /// Captures the first OnErrorResumeAsync exception, if a TCS was supplied. + private readonly TaskCompletionSource? _onError; + + /// Initializes a new instance of the class. + /// Optional TCS for capturing the first OnNextAsync value. + /// Optional TCS for capturing the first OnErrorResumeAsync exception. + public CapturingObserver( + TaskCompletionSource? onNext = null, + TaskCompletionSource? onError = null) + { + _onNext = onNext; + _onError = onError; + } + + /// + public ValueTask OnNextAsync(T value, CancellationToken cancellationToken) + { + _onNext?.TrySetResult(value); + return default; + } + + /// + public ValueTask OnErrorResumeAsync(Exception error, CancellationToken cancellationToken) + { + _onError?.TrySetResult(error); + return default; + } + + /// + public ValueTask OnCompletedAsync(Result result) => default; + + /// + public ValueTask DisposeAsync() => default; + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Multicast.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Multicast.cs index e9291b7..17c8f00 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Multicast.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Multicast.cs @@ -401,4 +401,36 @@ public void WhenRefCountDisposedTwice_ThenSecondDisposeIsNoop() disposable.Dispose(); disposable.Dispose(); } + + /// Verifies that calling ConnectAsync with a caller-supplied cancellation + /// token takes the linked-CTS slow path. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMulticastConnectAsyncWithCustomToken_ThenLinkedCtsPathTaken() + { + var subject = SubjectAsync.Create(); + var connectable = ObservableAsync.Return(1).Multicast(subject); + + using var cts = new CancellationTokenSource(); + await using var connection = await connectable.ConnectAsync(cts.Token); + + await Assert.That(connection).IsNotNull(); + } + + /// Verifies that disposing the connection handle twice is idempotent — the second + /// call hits the connection is null guard inside the dispose lambda. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMulticastConnectionDisposedTwice_ThenSecondIsNoOp() + { + var subject = SubjectAsync.Create(); + var connectable = ObservableAsync.Return(1).Multicast(subject); + + var connection = await connectable.ConnectAsync(CancellationToken.None); + + await connection.DisposeAsync(); + await connection.DisposeAsync(); + + await Assert.That(connection).IsNotNull(); + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Switch.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Switch.cs index 3fd720b..d57ce7e 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Switch.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Switch.cs @@ -516,4 +516,37 @@ await ObservableAsync.Return(ObservableAsync.Throw(error)) .Switch() .FirstAsync()); } + + /// Verifies that subscribing Switch with an already-cancelled token + /// short-circuits the subscription's cancellation chain immediately. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSwitchSubscribedWithAlreadyCancelledToken_ThenSubscriptionDisposes() + { + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await using var sub = await ObservableAsync.Return>(ObservableAsync.Return(1)) + .Switch() + .SubscribeAsync(static (_, _) => default, cts.Token); + + await Assert.That(sub).IsNotNull(); + } + + /// Verifies that subscribing Switch with a cancellable but not-yet-cancelled + /// token registers the external link and the registration fires on later cancellation. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSwitchExternalTokenCancelledAfterSubscribe_ThenRegistrationFires() + { + using var cts = new CancellationTokenSource(); + var outer = SubjectAsync.Create>(); + + await using var sub = await outer.Values + .Switch() + .SubscribeAsync(static (_, _) => default, cts.Token); + + await cts.CancelAsync(); + await Assert.That(sub).IsNotNull(); + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Zip.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Zip.cs index dada162..8fae11d 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Zip.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Zip.cs @@ -583,4 +583,39 @@ public async Task WhenZipLeftShorter_ThenCompletesEarly() await Assert.That(result).IsCollectionEqualTo([ZipPair11, ZipPair13]); } + + /// Verifies that subscribing Zip with an already-cancelled token + /// short-circuits the subscription's cancellation chain immediately. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenZipSubscribedWithAlreadyCancelledToken_ThenSubscriptionDisposes() + { + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await using var sub = await ObservableAsync.Range(1, 2) + .Zip(ObservableAsync.Range(10, 2), static (a, b) => a + b) + .SubscribeAsync(static (_, _) => default, cts.Token); + + await Assert.That(sub).IsNotNull(); + } + + /// Verifies that subscribing Zip with a cancellable but not-yet-cancelled + /// token registers the external link and the registration fires when the token is cancelled + /// after subscribe, tearing the subscription down. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenZipExternalTokenCancelledAfterSubscribe_ThenRegistrationFires() + { + using var cts = new CancellationTokenSource(); + var left = SubjectAsync.Create(); + var right = SubjectAsync.Create(); + + await using var sub = await left.Values + .Zip(right.Values, static (a, b) => a + b) + .SubscribeAsync(static (_, _) => default, cts.Token); + + await cts.CancelAsync(); + await Assert.That(sub).IsNotNull(); + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/ConcurrentSubjectBaseTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/ConcurrentSubjectBaseTests.cs new file mode 100644 index 0000000..e7aba77 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Async/ConcurrentSubjectBaseTests.cs @@ -0,0 +1,225 @@ +// 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.Collections.Immutable; +using ReactiveUI.Extensions.Async; +using ReactiveUI.Extensions.Async.Internals; +using ReactiveUI.Extensions.Async.Subjects; + +namespace ReactiveUI.Extensions.Tests.Async; + +/// Coverage for the static fan-out helpers in +/// — exercises empty / single / multi-observer paths and the +/// slow-path that uses when at +/// least one observer's hasn't completed synchronously. +[SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "TUnit requires instance methods")] +public class ConcurrentSubjectBaseTests +{ + /// Value forwarded by the OnNext fan-out tests. + private const int ForwardedValue = 42; + + /// Delay in milliseconds used to force the slow-path branch. + private const int SlowPathDelayMilliseconds = 5; + + /// Verifies that ForwardOnNextConcurrently with an empty observer list returns immediately. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenForwardOnNextEmpty_ThenCompletesImmediately() + { + var empty = ImmutableArray>.Empty; + + await Concurrent.ForwardOnNextConcurrently(empty, ForwardedValue, default); + + // No observers → nothing to assert beyond reaching this line without throwing. + await Assert.That(empty.Length).IsEqualTo(0); + } + + /// Verifies that ForwardOnNextConcurrently with a single observer forwards once. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenForwardOnNextSingleObserver_ThenForwardsOnce() + { + var capture = new IntCapture(); + var observers = ImmutableArray.Create>(MakeSync(capture)); + + await Concurrent.ForwardOnNextConcurrently(observers, ForwardedValue, default); + + await Assert.That(capture.Value).IsEqualTo(ForwardedValue); + } + + /// Verifies that the synchronous-fast-path branch fires every observer when each + /// returns a synchronously-completed . + /// A representing the asynchronous test operation. + [Test] + public async Task WhenForwardOnNextMultipleSync_ThenAllReceive() + { + var a = new IntCapture(); + var b = new IntCapture(); + var c = new IntCapture(); + + var observers = ImmutableArray.Create>( + MakeSync(a), + MakeSync(b), + MakeSync(c)); + + await Concurrent.ForwardOnNextConcurrently(observers, ForwardedValue, default); + + await Assert.That(a.Value).IsEqualTo(ForwardedValue); + await Assert.That(b.Value).IsEqualTo(ForwardedValue); + await Assert.That(c.Value).IsEqualTo(ForwardedValue); + } + + /// Verifies that the slow-path branch (Task.WhenAll) runs every observer when + /// any of them returns a non-completed . + /// A representing the asynchronous test operation. + [Test] + public async Task WhenForwardOnNextSlowPath_ThenWhenAllForwarded() + { + var a = new IntCapture(); + var b = new IntCapture(); + var c = new IntCapture(); + + var observers = ImmutableArray.Create>( + MakeSlow(a), + MakeSync(b), + MakeSlow(c)); + + await Concurrent.ForwardOnNextConcurrently(observers, ForwardedValue, default); + + await Assert.That(a.Value).IsEqualTo(ForwardedValue); + await Assert.That(b.Value).IsEqualTo(ForwardedValue); + await Assert.That(c.Value).IsEqualTo(ForwardedValue); + } + + /// Verifies the empty / single / slow-path branches of ForwardOnErrorResumeConcurrently. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenForwardOnErrorResume_ThenAllBranchesForward() + { + var emptyObservers = ImmutableArray>.Empty; + await Concurrent.ForwardOnErrorResumeConcurrently(emptyObservers, new InvalidOperationException("empty"), default); + + var singleCaught = new ErrorCapture(); + var single = ImmutableArray.Create>( + new AnonymousObserverAsync(static (_, _) => default, MakeErrorSync(singleCaught))); + var singleError = new InvalidOperationException("single"); + await Concurrent.ForwardOnErrorResumeConcurrently(single, singleError, default); + await Assert.That(singleCaught.Error).IsSameReferenceAs(singleError); + + var a = new ErrorCapture(); + var b = new ErrorCapture(); + var multi = ImmutableArray.Create>( + new AnonymousObserverAsync(static (_, _) => default, MakeErrorSlow(a)), + new AnonymousObserverAsync(static (_, _) => default, MakeErrorSync(b))); + var multiError = new InvalidOperationException("multi"); + await Concurrent.ForwardOnErrorResumeConcurrently(multi, multiError, default); + await Assert.That(a.Error).IsSameReferenceAs(multiError); + await Assert.That(b.Error).IsSameReferenceAs(multiError); + } + + /// Verifies the empty / single / slow-path branches of ForwardOnCompletedConcurrently. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenForwardOnCompleted_ThenAllBranchesForward() + { + var emptyObservers = ImmutableArray>.Empty; + await Concurrent.ForwardOnCompletedConcurrently(emptyObservers, Result.Success); + + var singleResult = new ResultCapture(); + var single = ImmutableArray.Create>( + new AnonymousObserverAsync(static (_, _) => default, null, MakeCompletedSync(singleResult))); + await Concurrent.ForwardOnCompletedConcurrently(single, Result.Success); + await Assert.That(singleResult.Result).IsEqualTo(Result.Success); + + var a = new ResultCapture(); + var b = new ResultCapture(); + var multi = ImmutableArray.Create>( + new AnonymousObserverAsync(static (_, _) => default, null, MakeCompletedSlow(a)), + new AnonymousObserverAsync(static (_, _) => default, null, MakeCompletedSync(b))); + await Concurrent.ForwardOnCompletedConcurrently(multi, Result.Success); + await Assert.That(a.Result).IsEqualTo(Result.Success); + await Assert.That(b.Result).IsEqualTo(Result.Success); + } + + /// Creates a synchronously-completing OnNext observer that captures the value. + /// The capture sink. + /// An observer whose OnNextAsync completes synchronously. + private static AnonymousObserverAsync MakeSync(IntCapture capture) => + new((x, _) => + { + capture.Value = x; + return default; + }); + + /// Creates an OnNext observer that delays before capturing — forces the slow path. + /// The capture sink. + /// An observer whose OnNextAsync completes asynchronously. + private static AnonymousObserverAsync MakeSlow(IntCapture capture) => + new(async (x, ct) => + { + await Task.Delay(SlowPathDelayMilliseconds, ct).ConfigureAwait(false); + capture.Value = x; + }); + + /// Synchronously-completing OnErrorResume handler that records the exception. + /// The capture sink. + /// An OnErrorResume delegate. + private static Func MakeErrorSync(ErrorCapture capture) => + (ex, _) => + { + capture.Error = ex; + return default; + }; + + /// OnErrorResume handler that delays before recording — forces the slow path. + /// The capture sink. + /// An OnErrorResume delegate. + private static Func MakeErrorSlow(ErrorCapture capture) => + async (ex, ct) => + { + await Task.Delay(SlowPathDelayMilliseconds, ct).ConfigureAwait(false); + capture.Error = ex; + }; + + /// Synchronously-completing OnCompleted handler that records the result. + /// The capture sink. + /// An OnCompleted delegate. + private static Func MakeCompletedSync(ResultCapture capture) => + r => + { + capture.Result = r; + return default; + }; + + /// OnCompleted handler that delays before recording — forces the slow path. + /// The capture sink. + /// An OnCompleted delegate. + private static Func MakeCompletedSlow(ResultCapture capture) => + async r => + { + await Task.Delay(SlowPathDelayMilliseconds, CancellationToken.None).ConfigureAwait(false); + capture.Result = r; + }; + + /// Mutable holder for an captured by an observer delegate. + private sealed class IntCapture + { + /// Gets or sets the captured value. + public int Value { get; set; } + } + + /// Mutable holder for an captured by an observer delegate. + private sealed class ErrorCapture + { + /// Gets or sets the captured exception. + public Exception? Error { get; set; } + } + + /// Mutable holder for a captured by an observer delegate. + private sealed class ResultCapture + { + /// Gets or sets the captured result. + public Result? Result { get; set; } + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/ErrorHandlingOperatorTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/ErrorHandlingOperatorTests.cs index bc02319..337d23d 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Async/ErrorHandlingOperatorTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Async/ErrorHandlingOperatorTests.cs @@ -356,6 +356,49 @@ public async Task WhenCatchDisposed_ThenDisposesSourceAndHandler() await sub.DisposeAsync(); } + /// Exercises the CatchObserver.DisposeAsyncCore catch branch — when the + /// handler-produced subscription throws on , the + /// failure is routed through rather than re-thrown. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCatchHandlerDisposeThrows_ThenRoutedToUnhandled() + { + var previousHandler = UnhandledExceptionHandler.CurrentHandler; + try + { + Exception? unhandled = null; + var unhandledTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + UnhandledExceptionHandler.Register(ex => + { + unhandled = ex; + unhandledTcs.TrySetResult(); + }); + + var disposeFailure = new InvalidOperationException("handler-dispose-failed"); + var source = ObservableAsync.Throw(new InvalidOperationException("fail")); + var handlerSubscribed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var handlerObservable = ObservableAsync.Create((_, _) => + { + handlerSubscribed.TrySetResult(); + return new ValueTask(new ThrowingDisposable(disposeFailure)); + }); + + var sub = await source.Catch(_ => handlerObservable) + .SubscribeAsync(static (_, _) => default, null, static _ => default); + + await handlerSubscribed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + await sub.DisposeAsync(); + await unhandledTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + await Assert.That(unhandled).IsSameReferenceAs(disposeFailure); + } + finally + { + UnhandledExceptionHandler.Register(previousHandler); + } + } + /// Tests that CatchAndIgnoreErrorResume invokes the unhandled exception handler for error resumes. /// A representing the asynchronous test operation. [Test] @@ -488,4 +531,12 @@ public async Task WhenRetryParameterless_ThenRetriesUntilSuccess() await Assert.That(result).IsCollectionEqualTo([SuccessValue]); await Assert.That(attempt).IsEqualTo(ExpectedAttempts); } + + /// Async disposable that throws on . + /// Used to verify dispose-failure routing in operators that swallow secondary errors. + private sealed class ThrowingDisposable(Exception error) : IAsyncDisposable + { + /// + public ValueTask DisposeAsync() => throw error; + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/FilteringOperatorTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/FilteringOperatorTests.cs index 616fdd3..d049485 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Async/FilteringOperatorTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Async/FilteringOperatorTests.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for full license information. using ReactiveUI.Extensions.Async; +using ReactiveUI.Extensions.Async.Subjects; namespace ReactiveUI.Extensions.Tests.Async; @@ -355,4 +356,362 @@ public async Task WhenDistinctUntilChangedBy_ThenDistinguishesByKey() await Assert.That(result).IsCollectionEqualTo(["aa", "ba"]); } + + /// Verifies that sync-predicate SkipWhile forwards a non-terminal upstream error + /// through its OnErrorResume path. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSkipWhileSyncSourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values + .SkipWhile(static x => x < 2) + .SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("skip-while-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that sync-predicate TakeWhile forwards a non-terminal upstream error + /// through its OnErrorResume path. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTakeWhileSyncSourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values + .TakeWhile(static x => x < 10) + .SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("take-while-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies the async-predicate SkipWhile sync-completed predicate path — + /// returning drops the value, returning latches + /// the gate and forwards. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSkipWhileAsyncWithSyncPredicate_ThenLatchesOnFalse() + { + var result = await ObservableAsync.Range(1, 5) + .SkipWhile(static (x, _) => new ValueTask(x < 3)) + .ToListAsync(); + + await Assert.That(result).IsCollectionEqualTo([ThirdElement, FourthElement, FifthElement]); + } + + /// Verifies the async-predicate TakeWhile sync-completed predicate path — + /// returning forwards, returning terminates. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTakeWhileAsyncWithSyncPredicate_ThenTerminatesOnFalse() + { + var result = await ObservableAsync.Range(1, 5) + .TakeWhile(static (x, _) => new ValueTask(x < 3)) + .ToListAsync(); + + await Assert.That(result).IsCollectionEqualTo([1, SecondElement]); + } + + /// Verifies that async-predicate SkipWhile forwards a non-terminal upstream error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSkipWhileAsyncSourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values + .SkipWhile(static (_, _) => new ValueTask(true)) + .SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("skip-while-async-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that async-predicate TakeWhile forwards a non-terminal upstream error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTakeWhileAsyncSourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values + .TakeWhile(static (_, _) => new ValueTask(true)) + .SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("take-while-async-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Exercises the DistinctObserver.OnErrorResumeAsyncCore forwarding — + /// upstream resumable errors propagate verbatim to the downstream observer. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDistinctSourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values + .Distinct() + .SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("distinct-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Exercises the DistinctByObserver.OnErrorResumeAsyncCore forwarding. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDistinctBySourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values + .DistinctBy(static x => x) + .SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("distinct-by-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Exercises the DistinctUntilChangedObserver.OnErrorResumeAsyncCore forwarding. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDistinctUntilChangedSourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values + .DistinctUntilChanged() + .SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("distinct-until-changed-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Exercises the DistinctUntilChangedByObserver.OnErrorResumeAsyncCore forwarding. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDistinctUntilChangedBySourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values + .DistinctUntilChangedBy(static x => x) + .SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("distinct-until-changed-by-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Exercises the WhereSyncObserver.OnErrorResumeAsyncCore forwarding — + /// the synchronous-predicate overload forwards upstream resumable errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhereSyncSourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values + .Where(static _ => true) + .SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("where-sync-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Exercises the SelectSyncObserver.OnErrorResumeAsyncCore forwarding — + /// upstream resumable errors propagate verbatim through the synchronous Select observer. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectSyncSourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values + .Select(static x => x + 1) + .SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("select-sync-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Exercises the SelectAsyncObserver.OnErrorResumeAsyncCore forwarding — + /// the async-selector overload forwards upstream resumable errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectAsyncSourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values + .Select(static (x, _) => new ValueTask(x + 1)) + .SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("select-async-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Exercises the WhereAsyncObserver.OnErrorResumeAsyncCore forwarding — + /// the async-predicate overload forwards upstream resumable errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhereAsyncSourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values + .Where(static (_, _) => new ValueTask(true)) + .SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("where-async-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/ObserveOnAsyncObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/ObserveOnAsyncObservableTests.cs new file mode 100644 index 0000000..41c47a6 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Async/ObserveOnAsyncObservableTests.cs @@ -0,0 +1,222 @@ +// 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.Extensions.Async; + +namespace ReactiveUI.Extensions.Tests.Async; + +/// Tests for — exercises the +/// forceYielding: true slow-path branches that switch context on every +/// OnNext / OnErrorResume / OnCompleted regardless of whether +/// the call site is already on the target context. +public class ObserveOnAsyncObservableTests +{ + /// Single sentinel emitted by the happy-path tests. + private const int Sentinel = 7; + + /// Verifies the forceYielding: true overload forwards values via the + /// context-switching slow path. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenForceYielding_ThenValueForwarded() + { + var result = await ObservableAsync.Return(Sentinel) + .ObserveOn(AsyncContext.Default, forceYielding: true) + .FirstAsync(); + + await Assert.That(result).IsEqualTo(Sentinel); + } + + /// Verifies the forceYielding: true overload routes OnErrorResume + /// through the context-switching slow path. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenForceYieldingSourceErrors_ThenErrorForwarded() + { + var expected = new InvalidOperationException("forced"); + InvalidOperationException? caught = null; + + try + { + await ObservableAsync.Throw(expected) + .ObserveOn(AsyncContext.Default, forceYielding: true) + .ToListAsync(); + } + catch (InvalidOperationException ex) + { + caught = ex; + } + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies the forceYielding: true overload routes the completion + /// notification through the context-switching slow path. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenForceYieldingSourceEmpty_ThenCompletesSuccessfully() + { + var result = await ObservableAsync.Empty() + .ObserveOn(AsyncContext.Default, forceYielding: true) + .ToListAsync(); + + await Assert.That(result).IsEmpty(); + } + + /// Verifies the SynchronizationContext + forceYielding: true overload + /// also forwards values. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSyncContextForceYielding_ThenEmits() + { + var ctx = SynchronizationContext.Current ?? new SynchronizationContext(); + + var result = await ObservableAsync.Return(Sentinel) + .ObserveOn(ctx, forceYielding: true) + .FirstAsync(); + + await Assert.That(result).IsEqualTo(Sentinel); + } + + /// Verifies that ObserveOn with a different SynchronizationContext routes + /// the error through the slow-path context-switch even when forceYielding is false. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenObserveOnDifferentContextSourceErrors_ThenForwardedViaSlowPath() + { + var expected = new InvalidOperationException("differing-context-error"); + InvalidOperationException? caught = null; + var customCtx = new SynchronizationContext(); + + try + { + await ObservableAsync.Throw(expected) + .ObserveOn(customCtx, forceYielding: false) + .ToListAsync(); + } + catch (InvalidOperationException ex) + { + caught = ex; + } + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that ObserveOn with a different SynchronizationContext routes + /// the completion through the slow-path context-switch even when forceYielding is false. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenObserveOnDifferentContextSourceEmpty_ThenCompletesViaSlowPath() + { + var customCtx = new SynchronizationContext(); + + var result = await ObservableAsync.Empty() + .ObserveOn(customCtx, forceYielding: false) + .ToListAsync(); + + await Assert.That(result).IsEmpty(); + } + + /// Verifies ObserveOnObserver.SwitchThenForwardAsync by calling it directly + /// — the slow path performs the context switch and then forwards the value downstream, + /// independent of the fast/slow choice in OnNextAsyncCore. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSwitchThenForwardAsyncInvokedDirectly_ThenValueForwarded() + { + var captured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var downstream = new CapturingAsyncObserver(captured); + var sut = new ObserveOnAsyncObservable.ObserveOnObserver(downstream, AsyncContext.Default, forceYielding: true); + + await sut.SwitchThenForwardAsync(Sentinel, CancellationToken.None); + + var received = await captured.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(received).IsEqualTo(Sentinel); + } + + /// Verifies ObserveOnObserver.SwitchThenErrorAsync by calling it directly. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSwitchThenErrorAsyncInvokedDirectly_ThenErrorForwarded() + { + var captured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var downstream = new CapturingAsyncObserver(captured); + var sut = new ObserveOnAsyncObservable.ObserveOnObserver(downstream, AsyncContext.Default, forceYielding: true); + var expected = new InvalidOperationException("slow-path-error"); + + await sut.SwitchThenErrorAsync(expected, CancellationToken.None); + + var received = await captured.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(received).IsSameReferenceAs(expected); + } + + /// Verifies ObserveOnObserver.SwitchThenCompletedAsync by calling it directly. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSwitchThenCompletedAsyncInvokedDirectly_ThenCompletionForwarded() + { + var captured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var downstream = new CapturingAsyncObserver(captured); + var sut = new ObserveOnAsyncObservable.ObserveOnObserver(downstream, AsyncContext.Default, forceYielding: true); + + await sut.SwitchThenCompletedAsync(Result.Success); + + var result = await captured.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(result.IsSuccess).IsTrue(); + } + + /// Test observer that captures the first OnNextAsync value, the first + /// OnErrorResumeAsync exception, and the OnCompletedAsync result via TCSes. + /// The element type. + private sealed class CapturingAsyncObserver : IObserverAsync + { + /// Captures the first OnNextAsync value, if a TCS was supplied. + private readonly TaskCompletionSource? _onNext; + + /// Captures the first OnErrorResumeAsync exception, if a TCS was supplied. + private readonly TaskCompletionSource? _onError; + + /// Captures the OnCompletedAsync result, if a TCS was supplied. + private readonly TaskCompletionSource? _onCompleted; + + /// Initializes a new instance of the class + /// with an OnNext capture target. + /// The TCS that receives the first OnNextAsync value. + public CapturingAsyncObserver(TaskCompletionSource onNext) => _onNext = onNext; + + /// Initializes a new instance of the class + /// with an OnErrorResume capture target. + /// The TCS that receives the first OnErrorResumeAsync exception. + public CapturingAsyncObserver(TaskCompletionSource onError) => _onError = onError; + + /// Initializes a new instance of the class + /// with an OnCompleted capture target. + /// The TCS that receives the OnCompletedAsync result. + public CapturingAsyncObserver(TaskCompletionSource onCompleted) => _onCompleted = onCompleted; + + /// + public ValueTask OnNextAsync(T value, CancellationToken cancellationToken) + { + _onNext?.TrySetResult(value); + return default; + } + + /// + public ValueTask OnErrorResumeAsync(Exception error, CancellationToken cancellationToken) + { + _onError?.TrySetResult(error); + return default; + } + + /// + public ValueTask OnCompletedAsync(Result result) + { + _onCompleted?.TrySetResult(result); + return default; + } + + /// + public ValueTask DisposeAsync() => default; + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/ParityHelpersFilterFusionsTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/ParityHelpersFilterFusionsTests.cs new file mode 100644 index 0000000..1774606 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Async/ParityHelpersFilterFusionsTests.cs @@ -0,0 +1,403 @@ +// 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.Extensions.Async; +using ReactiveUI.Extensions.Async.Subjects; + +namespace ReactiveUI.Extensions.Tests.Async; + +/// Coverage for the error-forwarding and edge cases of the fused +/// async filter operators in ParityHelpers.FilterFusions — +/// SkipWhileNull, WhereIsNotNull, LatestOrDefault, +/// WaitUntil, AsSignal, Not, WhereTrue, WhereFalse. +[SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "TUnit requires instance methods")] +public class ParityHelpersFilterFusionsTests +{ + /// Sentinel "found" sentinel string. + private const string Hit = "hit"; + + /// Expected count of bool outputs that pass / fail their filter. + private const int ExpectedBoolFilterCount = 2; + + /// Expected count of unit signals emitted by the AsSignal test. + private const int ExpectedSignalCount = 3; + + /// Predicate threshold for the WaitUntil test. + private const int WaitUntilThreshold = 3; + + /// Verifies that SkipWhileNull drops leading nulls then forwards every value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSkipWhileNull_ThenDropsLeadingNullsThenLatches() + { + string?[] inputs = [null, null, "a", null, "b"]; + + var result = await inputs.ToObservableAsync() + .SkipWhileNull() + .ToListAsync(); + + // After the first non-null, the gate opens and every subsequent value (including null!) flows. + // The implementation forwards `value!` past the gate; we assert the non-null prefix is correct + // and we receive at least the values after the gate opened. + await Assert.That(result.Count).IsGreaterThanOrEqualTo(1); + await Assert.That(result[0]).IsEqualTo("a"); + } + + /// Verifies that WhereIsNotNull strips nulls and forwards non-nulls. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhereIsNotNull_ThenForwardsNonNullsOnly() + { + string?[] inputs = [null, "a", null, "b", null]; + + var result = await inputs.ToObservableAsync() + .WhereIsNotNull() + .ToListAsync(); + + await Assert.That(result).IsCollectionEqualTo(["a", "b"]); + } + + /// Verifies that LatestOrDefault emits the seed first, then suppresses + /// values equal to the most-recent emitted, then forwards every distinct value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLatestOrDefault_ThenSeedFirstAndDistinctOnly() + { + const int Zero = 0; + const int One = 1; + const int Two = 2; + int[] inputs = [Zero, Zero, One, One, Two]; + + var result = await inputs.ToObservableAsync() + .LatestOrDefault(Zero) + .ToListAsync(); + + await Assert.That(result).IsCollectionEqualTo([Zero, One, Two]); + } + + /// Verifies that WaitUntil emits the first matching value and completes, + /// dropping subsequent values. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitUntilMatches_ThenEmitsFirstHitAndCompletes() + { + int[] inputs = [1, 2, 3, 4, 5]; + + var result = await inputs.ToObservableAsync() + .WaitUntil(static x => x >= WaitUntilThreshold) + .ToListAsync(); + + await Assert.That(result).IsCollectionEqualTo([WaitUntilThreshold]); + } + + /// Verifies that WaitUntil with no match completes empty. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitUntilNoMatch_ThenCompletesEmpty() + { + int[] inputs = [1, 2, 3]; + + var result = await inputs.ToObservableAsync() + .WaitUntil(static _ => false) + .ToListAsync(); + + await Assert.That(result).IsEmpty(); + } + + /// Verifies that AsSignal projects every emission to . + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAsSignal_ThenProjectsToUnit() + { + int[] inputs = [1, 2, 3]; + + var result = await inputs.ToObservableAsync() + .AsSignal() + .ToListAsync(); + + await Assert.That(result.Count).IsEqualTo(ExpectedSignalCount); + for (var i = 0; i < result.Count; i++) + { + await Assert.That(result[i]).IsEqualTo(Unit.Default); + } + } + + /// Verifies that Not negates every boolean emission. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenNot_ThenNegatesEvery() + { + bool[] inputs = [true, false, true]; + + var result = await inputs.ToObservableAsync() + .Not() + .ToListAsync(); + + await Assert.That(result).IsCollectionEqualTo([false, true, false]); + } + + /// Verifies that WhereTrue forwards only true values. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhereTrue_ThenForwardsOnlyTrue() + { + bool[] inputs = [true, false, true, false]; + + var result = await inputs.ToObservableAsync() + .WhereTrue() + .ToListAsync(); + + await Assert.That(result.Count).IsEqualTo(ExpectedBoolFilterCount); + for (var i = 0; i < result.Count; i++) + { + await Assert.That(result[i]).IsTrue(); + } + } + + /// Verifies that WhereFalse forwards only false values. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhereFalse_ThenForwardsOnlyFalse() + { + bool[] inputs = [true, false, true, false]; + + var result = await inputs.ToObservableAsync() + .WhereFalse() + .ToListAsync(); + + await Assert.That(result.Count).IsEqualTo(ExpectedBoolFilterCount); + for (var i = 0; i < result.Count; i++) + { + await Assert.That(result[i]).IsFalse(); + } + } + + /// Verifies that WhereIsNotNull forwards OnErrorResume downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhereIsNotNullSourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? received = null; + + await using var sub = await subject.Values + .WhereIsNotNull() + .SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + received = ex; + return default; + }); + + await subject.OnErrorResumeAsync(new InvalidOperationException(Hit), CancellationToken.None); + + await AsyncTestHelpers.WaitForConditionAsync( + () => received is not null, + TimeSpan.FromSeconds(5)); + + await Assert.That(received).IsNotNull(); + await Assert.That(received!.Message).IsEqualTo(Hit); + } + + /// Verifies that Pairwise forwards a non-terminal upstream error downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenPairwiseSourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values.Pairwise().SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("pairwise-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that SkipWhileNull forwards a non-terminal upstream error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSkipWhileNullSourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values.SkipWhileNull().SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("skip-while-null-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that LatestOrDefault forwards a non-terminal upstream error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLatestOrDefaultSourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values.LatestOrDefault(0).SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("latest-or-default-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that WaitUntil forwards a non-terminal upstream error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitUntilSourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values.WaitUntil(static _ => false).SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("wait-until-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that AsSignal forwards a non-terminal upstream error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAsSignalSourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values.AsSignal().SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("as-signal-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that Not forwards a non-terminal upstream error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAsyncNotSourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values.Not().SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("not-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that WhereTrue forwards a non-terminal upstream error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhereTrueSourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values.WhereTrue().SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("where-true-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that WhereFalse forwards a non-terminal upstream error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhereFalseSourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values.WhereFalse().SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("where-false-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/ParityHelpersOperatorFusionsTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/ParityHelpersOperatorFusionsTests.cs new file mode 100644 index 0000000..a87fef8 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Async/ParityHelpersOperatorFusionsTests.cs @@ -0,0 +1,854 @@ +// 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.Extensions.Async; +using ReactiveUI.Extensions.Async.Disposables; +using ReactiveUI.Extensions.Async.Subjects; + +namespace ReactiveUI.Extensions.Tests.Async; + +/// Edge-case coverage for the fused async operators in +/// ParityHelpers.OperatorFusions — async ScanWithInitial, +/// ThrottleDistinct upstream/downstream filtering, DebounceUntil +/// immediate-bypass branch, and the typed fast paths in ForEach +/// (array / IReadOnlyList / general IEnumerable). +[SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "TUnit requires instance methods")] +public class ParityHelpersOperatorFusionsTests +{ + /// Initial accumulator seed for scan tests. + private const int ScanSeed = 0; + + /// Throttle window in milliseconds for ThrottleDistinct tests. + private const int ThrottleWindowMilliseconds = 50; + + /// Sentinel one. + private const int One = 1; + + /// Sentinel two. + private const int Two = 2; + + /// Sentinel three. + private const int Three = 3; + + /// Sentinel four. + private const int Four = 4; + + /// Array sentinels for the array fast-path test. + private static readonly int[] ArraySlice1 = [One, Two]; + + /// Second array sentinels. + private static readonly int[] ArraySlice2 = [Three, Four]; + + /// Expected flat result for the array fast-path test. + private static readonly int[] ExpectedArrayFlat = [One, Two, Three, Four]; + + /// Expected flat result for the list and enumerable tests. + private static readonly int[] ExpectedListFlat = [One, Two, Three]; + + /// Inputs for the ScanWithInitial async-accumulator test. + private static readonly int[] ScanInputs = [One, Two, Three]; + + /// Inputs for the ThrottleDistinct rapid-values test. + private static readonly int[] ThrottleRapidInputs = [One, Two, Three]; + + /// Inputs for the DebounceUntil immediate-bypass test. + private static readonly int[] DebounceInputs = [One, Two, Three]; + + /// Inputs of all-equal values for the ThrottleDistinct duplicates test. + private static readonly int[] ThrottleDuplicateInputs = [One, One, One]; + + /// Verifies that the async-accumulator overload of ScanWithInitial + /// emits the seed first then every intermediate value produced by the asynchronous fold. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScanWithInitialAsync_ThenSeedThenAsyncFolded() + { + var result = await ScanInputs.ToObservableAsync() + .ScanWithInitial(ScanSeed, static async (acc, x, _) => + { + await Task.Yield(); + return acc + x; + }) + .ToListAsync(); + + int[] expected = + [ + ScanSeed, + One, + One + Two, + One + Two + Three + ]; + await Assert.That(result).IsCollectionEqualTo(expected); + } + + /// Verifies that ThrottleDistinct suppresses consecutive duplicates upstream + /// before any throttle work is scheduled. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleDistinctConsecutiveDuplicates_ThenSuppressesUpstream() + { + var result = await ThrottleDuplicateInputs.ToObservableAsync() + .ThrottleDistinct(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds)) + .ToListAsync(); + + // All inputs are equal — only one emission is ever scheduled, and the source completes + // before the throttle window elapses, so the pending emission must still flush exactly once. + await Assert.That(result.Count).IsLessThanOrEqualTo(1); + } + + /// Verifies that ThrottleDistinct with distinct rapid values respects the + /// no-consecutive-duplicates contract and never emits more than the input count. + /// (Pending throttled emissions are superseded by source completion — this is the + /// documented behavior, so a count-bound assertion is the appropriate check rather than + /// "at least one emission".) + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleDistinctRapidDistinctValues_ThenNoConsecutiveDuplicates() + { + var result = await ThrottleRapidInputs.ToObservableAsync() + .ThrottleDistinct(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds)) + .ToListAsync(); + + await Assert.That(result.Count).IsLessThanOrEqualTo(ThrottleRapidInputs.Length); + for (var i = 1; i < result.Count; i++) + { + await Assert.That(result[i]).IsNotEqualTo(result[i - 1]); + } + } + + /// Verifies that DebounceUntil with an always-true condition bypasses + /// the debounce window and emits inline. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDebounceUntilConditionAlwaysTrue_ThenEmitsImmediately() + { + var result = await DebounceInputs.ToObservableAsync() + .DebounceUntil(TimeSpan.FromSeconds(5), static _ => true) + .ToListAsync(); + + await Assert.That(result).IsCollectionEqualTo(DebounceInputs); + } + + /// Verifies that the array fast path of ForEach flattens an + /// IObservableAsync<T[]> into a flat sequence of elements. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenForEachOverArray_ThenUsesArrayFastPath() + { + IEnumerable[] arrays = [ArraySlice1, ArraySlice2]; + + var result = await arrays.ToObservableAsync() + .ForEach() + .ToListAsync(); + + await Assert.That(result).IsCollectionEqualTo(ExpectedArrayFlat); + } + + /// Verifies that the fast path of ForEach + /// flattens a list-typed source. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenForEachOverReadOnlyList_ThenUsesListFastPath() + { + var firstList = new List(ArraySlice1); + var secondList = new List(1) { Three }; + IEnumerable[] lists = [firstList, secondList]; + + var result = await lists.ToObservableAsync() + .ForEach() + .ToListAsync(); + + await Assert.That(result).IsCollectionEqualTo(ExpectedListFlat); + } + + /// Verifies that the general path of ForEach + /// flattens a non-array, non-list source. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenForEachOverGenericEnumerable_ThenUsesEnumeratorPath() + { + IEnumerable[] enumerables = [Enumerate(One, Two), Enumerate(Three)]; + + var result = await enumerables.ToObservableAsync() + .ForEach() + .ToListAsync(); + + await Assert.That(result).IsCollectionEqualTo(ExpectedListFlat); + } + + /// Verifies that Partition broadcasts an upstream non-terminal error to both + /// subscribed branches via the OnErrorResume path. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenPartitionSourceErrorResume_ThenBothBranchesReceiveError() + { + var subject = SubjectAsync.Create(); + var (evens, odds) = subject.Values.Partition(static x => x % Two == 0); + + Exception? evenError = null; + Exception? oddError = null; + var evenTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var oddTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var evenSub = await evens.SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + evenError = ex; + evenTcs.TrySetResult(); + return default; + }); + await using var oddSub = await odds.SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + oddError = ex; + oddTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("partition-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await Task.WhenAll(evenTcs.Task, oddTcs.Task).WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(evenError).IsSameReferenceAs(expected); + await Assert.That(oddError).IsSameReferenceAs(expected); + } + + /// Verifies that a branch subscriber attaching after the source has already + /// completed gets the cached terminal forwarded immediately. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenPartitionLateBranchSubscribesAfterCompletion_ThenCachedTerminalForwarded() + { + var subject = SubjectAsync.Create(); + var (evens, odds) = subject.Values.Partition(static x => x % Two == 0); + + var firstTask = evens.ToListAsync().AsTask(); + await subject.OnNextAsync(Two, CancellationToken.None); + await subject.OnCompletedAsync(Result.Success); + await firstTask; + + var lateValues = new List(); + var lateCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var lateSub = await odds.SubscribeAsync( + (v, _) => + { + lateValues.Add(v); + return default; + }, + (_, _) => default, + result => + { + lateCompleted.TrySetResult(); + return default; + }); + + await lateCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(lateValues).IsEmpty(); + } + + /// Verifies that DropIfBusy resets the busy flag and re-throws when the + /// async action throws synchronously (rather than returning a faulted task). + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDropIfBusyActionThrowsSynchronously_ThenBusyFlagResetAndErrorObserved() + { + var failure = new InvalidOperationException("sync-throw"); + InvalidOperationException? observed = null; + + try + { + await new[] { One }.ToObservableAsync() + .DropIfBusy(static (_, _) => throw new InvalidOperationException("sync-throw")) + .ToListAsync(); + } + catch (InvalidOperationException ex) + { + observed = ex; + } + + await Assert.That(observed).IsNotNull(); + await Assert.That(observed!.Message).IsEqualTo(failure.Message); + } + + /// Verifies that ScanWithInitial forwards a non-terminal upstream error + /// downstream while still emitting the seed. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScanWithInitialSourceErrorResumes_ThenForwardsDownstream() + { + var subject = SubjectAsync.Create(); + var values = new List(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values + .ScanWithInitial(ScanSeed, static (acc, x) => acc + x) + .SubscribeAsync( + (v, _) => + { + values.Add(v); + return default; + }, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("scan-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(values).IsCollectionEqualTo([ScanSeed]); + } + + /// Verifies that ThrottleDistinct forwards a non-terminal upstream error + /// downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleDistinctSourceErrorResumes_ThenForwardsDownstream() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values + .ThrottleDistinct(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds)) + .SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("throttle-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that DebounceUntil forwards a non-terminal upstream error + /// downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDebounceUntilSourceErrorResumes_ThenForwardsDownstream() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values + .DebounceUntil(TimeSpan.FromSeconds(5), static _ => false) + .SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("debounce-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that ForEach forwards a non-terminal upstream error downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenForEachSourceErrorResumes_ThenForwardsDownstream() + { + var subject = SubjectAsync.Create>(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values + .ForEach() + .SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("foreach-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that DropIfBusy forwards a non-terminal upstream error downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDropIfBusySourceErrorResumes_ThenForwardsDownstream() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values + .DropIfBusy(static (_, _) => default) + .SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("dropifbusy-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that DropIfBusy with a sync action but an asynchronously-completing + /// downstream takes the AwaitForwardAsync slow path and resets the busy flag in + /// its finally. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDropIfBusySyncActionAsyncDownstream_ThenAwaitForwardSlowPathResets() + { + var subject = SubjectAsync.Create(); + var values = new List(); + var emittedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values + .DropIfBusy(static (_, _) => default) + .SubscribeAsync(async (v, _) => + { + await Task.Yield(); + values.Add(v); + emittedTcs.TrySetResult(v); + }); + + await subject.OnNextAsync(One, CancellationToken.None); + await emittedTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + // After the slow path resets _isBusy, a second emission must also flow through. + var secondTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var sub2 = await subject.Values + .DropIfBusy(static (_, _) => default) + .SubscribeAsync(async (_, _) => + { + await Task.Yield(); + secondTcs.TrySetResult(); + }); + + await subject.OnNextAsync(Two, CancellationToken.None); + await secondTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + await Assert.That(values).Contains(One); + } + + /// Verifies that the async-accumulator ScanWithInitial overload forwards + /// upstream non-terminal errors downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScanWithInitialAsyncSourceErrorResumes_ThenForwardsDownstream() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values + .ScanWithInitial(ScanSeed, static (acc, x, _) => new ValueTask(acc + x)) + .SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("scan-async-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that a branch subscription disposes idempotently — the second + /// DisposeAsync is a no-op via the latched-int short-circuit. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenPartitionBranchDisposedTwice_ThenIdempotent() + { + var subject = SubjectAsync.Create(); + var (evens, _) = subject.Values.Partition(static x => x % Two == 0); + + var sub = await evens.SubscribeAsync(static (_, _) => default); + + await sub.DisposeAsync(); + await sub.DisposeAsync(); + + // Subsequent emissions must not throw and the result of pushing a value is captured. + await subject.OnNextAsync(Two, CancellationToken.None); + } + + /// Verifies that the ObserverAsync base class's LinkExternalCancellation + /// takes the already-cancelled fast path when a fused operator's sink is constructed with + /// a pre-cancelled subscribe token. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFusedOperatorSubscribedWithAlreadyCancelledToken_ThenSinkCancelsImmediately() + { + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + try + { + await using var sub = await new[] { One, Two }.ToObservableAsync() + .ScanWithInitial(ScanSeed, static (acc, x) => acc + x) + .SubscribeAsync(static (_, _) => default, cts.Token); + } + catch (OperationCanceledException) + { + // Expected — the sink is constructed with a cancelled token and the pipeline + // short-circuits via OperationCanceledException somewhere in the subscribe chain. + } + } + + /// Verifies that an unhandled exception thrown by the downstream observer inside + /// ThrottleDistinct's delayed-emit task is routed to + /// . + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleDistinctDownstreamThrowsInDelay_ThenRoutedToUnhandled() + { + var previousHandler = UnhandledExceptionHandler.CurrentHandler; + try + { + Exception? unhandled = null; + var unhandledTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + UnhandledExceptionHandler.Register(ex => + { + unhandled = ex; + unhandledTcs.TrySetResult(); + }); + + var subject = SubjectAsync.Create(); + var throwingObserver = new ThrowingAsyncObserver(new InvalidOperationException("downstream-throws")); + + await using var sub = await subject.Values + .ThrottleDistinct(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds)) + .SubscribeAsync(throwingObserver, CancellationToken.None); + + await subject.OnNextAsync(One, CancellationToken.None); + await unhandledTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + await Assert.That(unhandled).IsNotNull(); + await Assert.That(unhandled!.Message).IsEqualTo("downstream-throws"); + } + finally + { + UnhandledExceptionHandler.Register(previousHandler); + } + } + + /// Verifies that an exception thrown by the downstream observer inside Throttle's + /// post-delay forwarding is caught by the operator's catch (Exception e) block and + /// routed through . + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleDownstreamThrowsInDelay_ThenRoutedToUnhandled() + { + var previousHandler = UnhandledExceptionHandler.CurrentHandler; + try + { + Exception? unhandled = null; + var unhandledTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + UnhandledExceptionHandler.Register(ex => + { + unhandled = ex; + unhandledTcs.TrySetResult(); + }); + + var subject = SubjectAsync.Create(); + var throwingObserver = new ThrowingAsyncObserver(new InvalidOperationException("throttle-downstream-throws")); + + await using var sub = await subject.Values + .Throttle(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds)) + .SubscribeAsync(throwingObserver, CancellationToken.None); + + await subject.OnNextAsync(One, CancellationToken.None); + await unhandledTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + await Assert.That(unhandled).IsNotNull(); + await Assert.That(unhandled!.Message).IsEqualTo("throttle-downstream-throws"); + } + finally + { + UnhandledExceptionHandler.Register(previousHandler); + } + } + + /// Exercises the !IsCurrentEmission(id) guard inside DebounceUntil's + /// DelayAndEmitAsync — when a later emission supersedes the current pending one + /// before its debounce window elapses, the older delayed-emit task wakes, sees its id is + /// stale, and returns early without forwarding. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDebounceUntilSecondEmissionSupersedesFirst_ThenStaleDelayDropsValue() + { + var subject = SubjectAsync.Create(); + var values = new List(); + var emitted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values + .DebounceUntil(TimeSpan.FromMilliseconds(80), static _ => false) + .SubscribeAsync( + (v, _) => + { + values.Add(v); + emitted.TrySetResult(); + return default; + }); + + await subject.OnNextAsync(One, CancellationToken.None); + await subject.OnNextAsync(Two, CancellationToken.None); + + await emitted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Task.Delay(ThrottleWindowMilliseconds); + + await Assert.That(values).IsCollectionEqualTo([Two]); + } + + /// Verifies that an unhandled exception thrown by the downstream observer inside + /// DebounceUntil's delayed-emit task is routed to . + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDebounceUntilDownstreamThrowsInDelay_ThenRoutedToUnhandled() + { + var previousHandler = UnhandledExceptionHandler.CurrentHandler; + try + { + Exception? unhandled = null; + var unhandledTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + UnhandledExceptionHandler.Register(ex => + { + unhandled = ex; + unhandledTcs.TrySetResult(); + }); + + var subject = SubjectAsync.Create(); + var throwingObserver = new ThrowingAsyncObserver(new InvalidOperationException("debounce-downstream-throws")); + + await using var sub = await subject.Values + .DebounceUntil(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds), static _ => false) + .SubscribeAsync(throwingObserver, CancellationToken.None); + + await subject.OnNextAsync(One, CancellationToken.None); + await unhandledTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + await Assert.That(unhandled).IsNotNull(); + await Assert.That(unhandled!.Message).IsEqualTo("debounce-downstream-throws"); + } + finally + { + UnhandledExceptionHandler.Register(previousHandler); + } + } + + /// Verifies that Partition drops upstream values whose predicate matches a + /// branch that has no current subscriber — exercises the + /// target?.OnNextAsync(...) ?? default null-target path. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenPartitionEmitsWhileMatchingBranchUnsubscribed_ThenDropped() + { + var subject = SubjectAsync.Create(); + var (evens, _) = subject.Values.Partition(static x => x % Two == 0); + + var values = new List(); + await using var sub = await evens.SubscribeAsync((v, _) => + { + values.Add(v); + return default; + }); + + // Odd value: matches the false branch, which has no subscriber. + await subject.OnNextAsync(One, CancellationToken.None); + + // Even value: matches the true branch. + await subject.OnNextAsync(Two, CancellationToken.None); + + await Assert.That(values).IsCollectionEqualTo([Two]); + } + + /// Verifies that + /// returns when the id has been superseded by a newer upstream emission. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleDistinctTryClaimEmissionSuperseded_ThenReturnsFalse() + { + var observer = new ObservableAsync.ThrottleDistinctObservable.ThrottleDistinctObserver( + new NoOpAsyncObserver(), + TimeSpan.FromHours(1), + TimeProvider.System, + CancellationToken.None); + + // Drive _id forward by two emissions; the first pending delay's id (1) is then stale. + await observer.OnNextAsync(One, CancellationToken.None); + await observer.OnNextAsync(Two, CancellationToken.None); + + var claimed = observer.TryClaimEmission(One, id: 1); + + await Assert.That(claimed).IsFalse(); + } + + /// Verifies that + /// returns when the value is a duplicate of the most-recently-emitted one. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleDistinctTryClaimEmissionDuplicate_ThenReturnsFalse() + { + var observer = new ObservableAsync.ThrottleDistinctObservable.ThrottleDistinctObserver( + new NoOpAsyncObserver(), + TimeSpan.FromHours(1), + TimeProvider.System, + CancellationToken.None); + + await observer.OnNextAsync(One, CancellationToken.None); + + // First claim latches the downstream-distinct state with value One. + var firstClaim = observer.TryClaimEmission(One, id: 1); + + // Drive another upstream so id matches the second claim. + await observer.OnNextAsync(Two, CancellationToken.None); + + // Re-claim with the previously-emitted value at the new id — rejected by the + // downstream-distinct check. + var secondClaim = observer.TryClaimEmission(One, id: 2); + + await Assert.That(firstClaim).IsTrue(); + await Assert.That(secondClaim).IsFalse(); + } + + /// Verifies that + /// returns for the most-recent id and for + /// stale ids. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDebounceUntilIsCurrentEmission_ThenMatchesIdState() + { + var observer = new ObservableAsync.DebounceUntilObservable.DebounceUntilObserver( + new NoOpAsyncObserver(), + TimeSpan.FromHours(1), + static _ => false, + TimeProvider.System, + CancellationToken.None); + + await observer.OnNextAsync(One, CancellationToken.None); + await observer.OnNextAsync(Two, CancellationToken.None); + + await Assert.That(observer.IsCurrentEmission(id: 2)).IsTrue(); + await Assert.That(observer.IsCurrentEmission(id: 1)).IsFalse(); + } + + /// Verifies that + /// returns when both branches have already been disposed by the time + /// the source subscription returns — the disposeNow race fast-path that is otherwise only + /// reachable through a real concurrency race. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenPartitionTryAttachSourceSubscriptionAndBothBranchesGone_ThenReturnsFalse() + { + var subject = SubjectAsync.Create(); + var coordinator = new ObservableAsync.PartitionCoordinator(subject.Values, static x => x % Two == 0); + + // Subscribe then immediately dispose so both branch slots are null. + var sub = await coordinator.TrueBranch.SubscribeAsync(static (_, _) => default); + await sub.DisposeAsync(); + + var attached = coordinator.TryAttachSourceSubscription(DisposableAsync.Empty); + + await Assert.That(attached).IsFalse(); + } + + /// Verifies that + /// returns when at least one branch is still alive. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenPartitionTryAttachSourceSubscriptionAndBranchAlive_ThenReturnsTrue() + { + var subject = SubjectAsync.Create(); + var coordinator = new ObservableAsync.PartitionCoordinator(subject.Values, static x => x % Two == 0); + + await using var sub = await coordinator.TrueBranch.SubscribeAsync(static (_, _) => default); + + var attached = coordinator.TryAttachSourceSubscription(DisposableAsync.Empty); + + await Assert.That(attached).IsTrue(); + } + + /// Yields values as a generic (neither array nor list) + /// to drive the slow-path branch of ForEach. + /// Values to yield. + /// A lazily-evaluated enumerable. + private static IEnumerable Enumerate(params int[] values) + { + foreach (var v in values) + { + yield return v; + } + } + + /// Bare-bones downstream async observer that throws a given exception inside + /// OnNextAsync. Bypassing the base class is intentional + /// — the base class would otherwise swallow synchronous throws and route them through + /// , never letting the exception propagate up to the + /// upstream operator's catch (Exception e) block under test. + /// The element type. + /// The exception to throw on every emission. + private sealed class ThrowingAsyncObserver(Exception error) : IObserverAsync + { + /// + public ValueTask OnNextAsync(T value, CancellationToken cancellationToken) => + throw error; + + /// + public ValueTask OnErrorResumeAsync(Exception err, CancellationToken cancellationToken) => + default; + + /// + public ValueTask OnCompletedAsync(Result result) => default; + + /// + public ValueTask DisposeAsync() => default; + } + + /// No-op async observer used as a downstream stand-in for direct unit tests of + /// observer-internal decision methods. + /// The element type. + private sealed class NoOpAsyncObserver : IObserverAsync + { + /// + public ValueTask OnNextAsync(T value, CancellationToken cancellationToken) => default; + + /// + public ValueTask OnErrorResumeAsync(Exception error, CancellationToken cancellationToken) => default; + + /// + public ValueTask OnCompletedAsync(Result result) => default; + + /// + public ValueTask DisposeAsync() => default; + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/ResultAndInfrastructureTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/ResultAndInfrastructureTests.cs index a8fe24e..0a5d638 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Async/ResultAndInfrastructureTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Async/ResultAndInfrastructureTests.cs @@ -287,6 +287,60 @@ await SubscriptionHelper.SubscribeAndDisposeOnFailureAsync( await Assert.That(disposed).IsTrue(); } + /// Verifies that a synchronous throw from OnErrorResumeAsyncCore is caught + /// by and routed to + /// . + /// A representing the asynchronous test operation. + [Test] + public async Task WhenObserverOnErrorResumeCoreThrowsSync_ThenRoutedToUnhandled() + { + Exception? unhandled = null; + var unhandledTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + UnhandledExceptionHandler.Register(ex => + { + unhandled = ex; + unhandledTcs.TrySetResult(); + }); + + var observer = new TestableObserverAsync( + onErrorResumeAsyncCore: static (_, _) => throw new InvalidOperationException("sync-throw")); + + await observer.OnErrorResumeAsync(new InvalidOperationException("original"), CancellationToken.None); + + await unhandledTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(unhandled).IsNotNull(); + await Assert.That(unhandled!.Message).IsEqualTo("sync-throw"); + } + + /// Verifies that an asynchronous throw from OnCompletedAsyncCore is caught + /// by 's slow path and routed to + /// . + /// A representing the asynchronous test operation. + [Test] + public async Task WhenObserverOnCompletedCoreThrowsAsync_ThenRoutedToUnhandled() + { + Exception? unhandled = null; + var unhandledTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + UnhandledExceptionHandler.Register(ex => + { + unhandled = ex; + unhandledTcs.TrySetResult(); + }); + + var observer = new TestableObserverAsync( + onCompletedAsyncCore: static async _ => + { + await Task.Yield(); + throw new InvalidOperationException("async-throw"); + }); + + await observer.OnCompletedAsync(Result.Success); + + await unhandledTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(unhandled).IsNotNull(); + await Assert.That(unhandled!.Message).IsEqualTo("async-throw"); + } + /// /// A concrete implementation for testing, with /// configurable behavior for each virtual method. diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/SubjectTests.BehaviorAndReplay.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/SubjectTests.BehaviorAndReplay.cs index 349dd2d..caffef6 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Async/SubjectTests.BehaviorAndReplay.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Async/SubjectTests.BehaviorAndReplay.cs @@ -685,4 +685,61 @@ public async Task WhenSubscribeToCompletedReplayLatest_ThenObserverReceivesImmed await Assert.That(lateResult).IsNotNull(); await Assert.That(lateResult!.Value.IsSuccess).IsTrue(); } + + /// Verifies that the replay-latest subject's OnNextAsync with a caller-supplied + /// cancellation token takes the linked-CTS slow path. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenReplayLatestOnNextWithCustomToken_ThenForwardsValue() + { + var subject = SubjectAsync.CreateReplayLatest(new ReplayLatestSubjectCreationOptions + { + PublishingOption = PublishingOption.Concurrent, + IsStateless = false, + }); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values.SubscribeAsync( + (v, _) => + { + tcs.TrySetResult(v); + return default; + }); + + using var cts = new CancellationTokenSource(); + const int LinkedCtsValue = 11; + await subject.OnNextAsync(LinkedCtsValue, cts.Token); + + var received = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(received).IsEqualTo(LinkedCtsValue); + } + + /// Verifies that the replay-latest subject's OnErrorResumeAsync with a + /// caller-supplied cancellation token takes the linked-CTS slow path. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenReplayLatestOnErrorResumeWithCustomToken_ThenForwardsError() + { + var subject = SubjectAsync.CreateReplayLatest(new ReplayLatestSubjectCreationOptions + { + PublishingOption = PublishingOption.Concurrent, + IsStateless = false, + }); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values.SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + tcs.TrySetResult(ex); + return default; + }); + + var expected = new InvalidOperationException("linked-cts"); + using var cts = new CancellationTokenSource(); + await subject.OnErrorResumeAsync(expected, cts.Token); + + var received = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(received).IsSameReferenceAs(expected); + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/TakeUntilOperatorTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/TakeUntilOperatorTests.cs index 247c136..54ca3a1 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Async/TakeUntilOperatorTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Async/TakeUntilOperatorTests.cs @@ -703,4 +703,100 @@ public async Task WhenTakeUntilCompletionDelegateSuccess_ThenCompletesSequence() await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsSuccess).IsTrue(); } + + /// Verifies the two-argument TakeUntil(other, cancellationToken) overload. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTakeUntilOtherWithCancellationToken_ThenCompletesOnCancellation() + { + using var cts = new CancellationTokenSource(); + var source = SubjectAsync.Create(); + var other = SubjectAsync.Create(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await source.Values.TakeUntil(other.Values, cts.Token).SubscribeAsync( + static (_, _) => default, + null, + _ => + { + completed.TrySetResult(); + return default; + }); + + await cts.CancelAsync(); + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + + /// Verifies the two-argument TakeUntil(task, cancellationToken) overload. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTakeUntilTaskWithCancellationToken_ThenCompletesOnCancellation() + { + using var cts = new CancellationTokenSource(); + var source = SubjectAsync.Create(); + var taskTcs = new TaskCompletionSource(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await source.Values.TakeUntil(taskTcs.Task, cts.Token).SubscribeAsync( + static (_, _) => default, + null, + _ => + { + completed.TrySetResult(); + return default; + }); + + await cts.CancelAsync(); + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + + /// Verifies the predicate overload with a cancellable token reaches the + /// CT-linked branch. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTakeUntilPredicateWithCancellationToken_ThenCompletesOnCancellation() + { + using var cts = new CancellationTokenSource(); + var source = SubjectAsync.Create(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await source.Values + .TakeUntil(static _ => false, cts.Token) + .SubscribeAsync( + static (_, _) => default, + null, + _ => + { + completed.TrySetResult(); + return default; + }); + + await cts.CancelAsync(); + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + + /// Verifies the async-predicate overload with a cancellable token reaches the + /// CT-linked branch. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTakeUntilAsyncPredicateWithCancellationToken_ThenCompletesOnCancellation() + { + using var cts = new CancellationTokenSource(); + var source = SubjectAsync.Create(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await source.Values + .TakeUntil(static (_, _) => new ValueTask(false), cts.Token) + .SubscribeAsync( + static (_, _) => default, + null, + _ => + { + completed.TrySetResult(); + return default; + }); + + await cts.CancelAsync(); + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/TerminalOperatorTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/TerminalOperatorTests.cs index ceb5f3b..d77b934 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Async/TerminalOperatorTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Async/TerminalOperatorTests.cs @@ -1317,6 +1317,26 @@ public async Task WhenContainsAsyncValueNotFound_ThenReturnsFalse() await Assert.That(result).IsFalse(); } + /// Exercises the ContainsAsync(value, comparer) overload — the no-cancellation + /// shortcut that forwards to the full overload with . + /// A representing the asynchronous test operation. + [Test] + public async Task WhenContainsAsyncWithComparerOverload_ThenForwardsResult() + { + var result = await ObservableAsync.Range(1, 3).ContainsAsync(2, EqualityComparer.Default); + await Assert.That(result).IsTrue(); + } + + /// Exercises the ContainsAsync(value, cancellationToken) overload — the + /// no-comparer shortcut that forwards to the full overload with a null comparer. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenContainsAsyncWithCancellationTokenOverload_ThenForwardsResult() + { + var result = await ObservableAsync.Range(1, 3).ContainsAsync(2, CancellationToken.None); + await Assert.That(result).IsTrue(); + } + /// Tests CountAsync with predicate that filters some elements. /// A representing the asynchronous test operation. [Test] @@ -1398,4 +1418,12 @@ await Assert.ThrowsAsync(async () => [Test] public void WhenWrapWithNullObserver_ThenThrowsArgumentNullException() => Assert.Throws(() => ObservableAsync.Wrap(null!)); + + /// Verifies the async-callback ForEachAsync overload throws when the + /// callback delegate is null. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenForEachAsyncCallbackNull_ThenThrowsArgumentNullException() => + await Assert.ThrowsAsync(async () => + await ObservableAsync.Return(1).ForEachAsync(null!, CancellationToken.None)); } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/TimeBasedOperatorTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/TimeBasedOperatorTests.cs index 4de9bcf..40b8286 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Async/TimeBasedOperatorTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Async/TimeBasedOperatorTests.cs @@ -924,6 +924,40 @@ public async Task WhenTimeoutResetsOnValue_ThenDoesNotFire() await Assert.That(result).IsCollectionEqualTo([1, ExpectedSecond, ExpectedThird]); } + /// Verifies that an exception thrown by the downstream observer's + /// OnCompletedAsync during a Timeout firing is routed to + /// . + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTimeoutFiresAndDownstreamCompletionThrows_ThenRoutedToUnhandled() + { + var previousHandler = UnhandledExceptionHandler.CurrentHandler; + try + { + Exception? unhandled = null; + var unhandledTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + UnhandledExceptionHandler.Register(ex => + { + unhandled = ex; + unhandledTcs.TrySetResult(); + }); + + var throwing = new TimeoutThrowingObserver(new InvalidOperationException("completion-failed")); + + await using var sub = await ObservableAsync.Never() + .Timeout(TimeSpan.FromMilliseconds(1)) + .SubscribeAsync(throwing, CancellationToken.None); + + await unhandledTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(unhandled).IsNotNull(); + await Assert.That(unhandled!.Message).IsEqualTo("completion-failed"); + } + finally + { + UnhandledExceptionHandler.Register(previousHandler); + } + } + /// /// A custom that delegates timer creation to the system provider. /// Used to exercise the non-system code paths in Interval and Timer operators. @@ -1010,4 +1044,23 @@ public void Dispose() public ValueTask DisposeAsync() => default; } } + + /// Bare-bones downstream observer that throws from OnCompletedAsync to + /// exercise the catch block in Timeout's FireTimeoutAsync. + /// The element type. + /// The exception to throw on completion. + private sealed class TimeoutThrowingObserver(Exception error) : IObserverAsync + { + /// + public ValueTask OnNextAsync(T value, CancellationToken cancellationToken) => default; + + /// + public ValueTask OnErrorResumeAsync(Exception err, CancellationToken cancellationToken) => default; + + /// + public ValueTask OnCompletedAsync(Result result) => throw error; + + /// + public ValueTask DisposeAsync() => default; + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/TransformationOperatorTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/TransformationOperatorTests.cs index ba391e2..8833ed5 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Async/TransformationOperatorTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Async/TransformationOperatorTests.cs @@ -14,6 +14,9 @@ namespace ReactiveUI.Extensions.Tests.Async; /// public class TransformationOperatorTests { + /// Number of inputs fed into the async-accumulator Scan sync-result test. + private const int ScanInputCount = 3; + /// Hoisted source array used by tests (was inline literal). private static readonly int[] Sequence123456 = [1, 2, 3, 4, 5, 6]; @@ -1013,6 +1016,78 @@ await ObservableAsync.Using( await Assert.That(disposed).IsTrue(); } + /// Verifies the async-accumulator Scan overload's sync-completed fast path — + /// returning a synchronously-completed from the accumulator + /// takes the inline pending.Result branch. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScanAsyncAccumulatorReturnsSync_ThenForwardsAccumulator() + { + const int ThirdRunningTotal = 3; + const int SixthRunningTotal = 6; + var result = await ObservableAsync.Range(1, ScanInputCount) + .Scan(0, static (acc, x, _) => new ValueTask(acc + x)) + .ToListAsync(); + + await Assert.That(result).IsCollectionEqualTo([1, ThirdRunningTotal, SixthRunningTotal]); + } + + /// Verifies that the sync-accumulator Scan overload forwards a non-terminal + /// upstream error downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScanSyncSourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values + .Scan(0, static (acc, x) => acc + x) + .SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("scan-sync-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that the async-accumulator Scan overload forwards a non-terminal + /// upstream error downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScanAsyncSourceErrorResume_ThenForwarded() + { + var subject = SubjectAsync.Create(); + Exception? caught = null; + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subject.Values + .Scan(0, static (acc, x, _) => new ValueTask(acc + x)) + .SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + errorTcs.TrySetResult(); + return default; + }); + + var expected = new InvalidOperationException("scan-async-error"); + await subject.OnErrorResumeAsync(expected, CancellationToken.None); + + await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + /// /// A raw implementation that throws a specified exception /// from . Unlike , this diff --git a/src/tests/ReactiveUI.Extensions.Tests/CurrentValueSubjectTests.MultiObserver.cs b/src/tests/ReactiveUI.Extensions.Tests/CurrentValueSubjectTests.MultiObserver.cs index a8fbd78..e5b1c60 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/CurrentValueSubjectTests.MultiObserver.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/CurrentValueSubjectTests.MultiObserver.cs @@ -185,4 +185,44 @@ public async Task WhenMultipleObserversAndOnError_ThenAllReceiveError() await Assert.That(errB).IsSameReferenceAs(expected); await Assert.That(errC).IsSameReferenceAs(expected); } + + /// Verifies that OnCompleted broadcasts to multiple observers and a + /// subsequent OnCompleted is a no-op. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMultipleObserversAndOnCompleted_ThenAllReceiveCompletionAndSecondIsNoOp() + { + using var subject = new CurrentValueSubject(MultiInitialValue); + var completedA = 0; + var completedB = 0; + var completedC = 0; + + using var subA = subject.Subscribe(static _ => { }, () => completedA++); + using var subB = subject.Subscribe(static _ => { }, () => completedB++); + using var subC = subject.Subscribe(static _ => { }, () => completedC++); + + subject.OnCompleted(); + subject.OnCompleted(); + + await Assert.That(completedA).IsEqualTo(1); + await Assert.That(completedB).IsEqualTo(1); + await Assert.That(completedC).IsEqualTo(1); + } + + /// Verifies that disposing the same subscription twice is idempotent. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscriptionDisposedTwice_ThenIdempotent() + { + using var subject = new CurrentValueSubject(MultiInitialValue); + var values = new List(); + + var sub = subject.Subscribe(values.Add); + sub.Dispose(); + sub.Dispose(); + + subject.OnNext(MultiInitialValue + 1); + + await Assert.That(values).IsCollectionEqualTo([MultiInitialValue]); + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Internal/ActionDisposableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Internal/ActionDisposableTests.cs new file mode 100644 index 0000000..b45679f --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Internal/ActionDisposableTests.cs @@ -0,0 +1,27 @@ +// 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.Extensions.Internal.Disposables; + +namespace ReactiveUI.Extensions.Tests.Internal; + +/// Tests for — verifies the dispose action runs exactly +/// once regardless of how many times Dispose is called. +public class ActionDisposableTests +{ + /// Verifies the action is invoked exactly once across repeated Dispose calls. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenActionDisposableDisposedTwice_ThenActionInvokedExactlyOnce() + { + var invocations = 0; + var disposable = new ActionDisposable(() => invocations++); + + disposable.Dispose(); + disposable.Dispose(); + disposable.Dispose(); + + await Assert.That(invocations).IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Internal/ConcurrencyRaceHelpersTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Internal/ConcurrencyRaceHelpersTests.cs new file mode 100644 index 0000000..2ee9588 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Internal/ConcurrencyRaceHelpersTests.cs @@ -0,0 +1,75 @@ +// 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.Extensions.Internal; + +namespace ReactiveUI.Extensions.Tests.Internal; + +/// Direct unit tests for — both race-claim +/// primitives are pure functions over their inputs and every branch is exercised here. +public class ConcurrencyRaceHelpersTests +{ + /// Sentinel for the "not yet claimed" state in the tests. + private const int Open = 0; + + /// Sentinel for the "claimed" state in the tests. + private const int Claimed = 1; + + /// Verifies succeeds when the state + /// is open and transitions it to the claimed sentinel. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTryClaimOpen_ThenReturnsTrueAndTransitions() + { + var state = Open; + + var claimed = ConcurrencyRaceHelpers.TryClaim(ref state, Open, Claimed); + + await Assert.That(claimed).IsTrue(); + await Assert.That(state).IsEqualTo(Claimed); + } + + /// Verifies returns false when the + /// state is already claimed and does not mutate it further. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTryClaimAlreadyClaimed_ThenReturnsFalse() + { + var state = Claimed; + + var claimed = ConcurrencyRaceHelpers.TryClaim(ref state, Open, Claimed); + + await Assert.That(claimed).IsFalse(); + await Assert.That(state).IsEqualTo(Claimed); + } + + /// Verifies returns + /// when called on an open CTS and the cancellation goes through. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTryCancelOpenCts_ThenReturnsTrueAndTokenCancels() + { + var cts = new CancellationTokenSource(); + + var succeeded = await ConcurrencyRaceHelpers.TryCancelAsync(cts); + + await Assert.That(succeeded).IsTrue(); + await Assert.That(cts.IsCancellationRequested).IsTrue(); + } + + /// Verifies returns + /// when called on a disposed CTS and silently swallows the + /// . + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTryCancelDisposedCts_ThenReturnsFalseAndSwallowsObjectDisposed() + { + var cts = new CancellationTokenSource(); + cts.Dispose(); + + var succeeded = await ConcurrencyRaceHelpers.TryCancelAsync(cts); + + await Assert.That(succeeded).IsFalse(); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Internal/DelegateObserverTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Internal/DelegateObserverTests.cs new file mode 100644 index 0000000..af591dd --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Internal/DelegateObserverTests.cs @@ -0,0 +1,65 @@ +// 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.Extensions.Internal; + +namespace ReactiveUI.Extensions.Tests.Internal; + +/// Tests for — verifies the null-callback branches +/// for OnError and OnCompleted as well as the all-callbacks-supplied happy path. +public class DelegateObserverTests +{ + /// Sentinel value emitted in single-value cases. + private const int Sentinel = 42; + + /// Verifies that omitting the error callback still allows OnError to be invoked safely. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenErrorCallbackNull_ThenOnErrorIsNoOp() + { + var values = new List(); + var observer = new DelegateObserver(values.Add); + + observer.OnNext(1); + observer.OnError(new InvalidOperationException("ignored")); + + await Assert.That(values).IsCollectionEqualTo([1]); + } + + /// Verifies that omitting the completion callback still allows OnCompleted to be invoked safely. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCompletedCallbackNull_ThenOnCompletedIsNoOp() + { + var values = new List(); + var observer = new DelegateObserver(values.Add); + + observer.OnNext(Sentinel); + observer.OnCompleted(); + + await Assert.That(values).IsCollectionEqualTo([Sentinel]); + } + + /// Verifies all three callbacks fire when supplied. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAllCallbacksSupplied_ThenEachInvoked() + { + var values = new List(); + Exception? caught = null; + var completed = false; + var observer = new DelegateObserver( + values.Add, + ex => caught = ex, + () => completed = true); + + observer.OnNext(1); + observer.OnError(new InvalidOperationException("boom")); + observer.OnCompleted(); + + await Assert.That(values).IsCollectionEqualTo([1]); + await Assert.That(caught).IsTypeOf(); + await Assert.That(completed).IsTrue(); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Internal/DisposableSlotHelperTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Internal/DisposableSlotHelperTests.cs new file mode 100644 index 0000000..1d60155 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Internal/DisposableSlotHelperTests.cs @@ -0,0 +1,119 @@ +// 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.Extensions.Internal.Disposables; + +namespace ReactiveUI.Extensions.Tests.Internal; + +/// Direct unit tests for . Covers every reachable +/// branch — the already-disposed pre-check, the steady-state assign, the swap-disposes-previous +/// path, and the idempotent TryDispose latch. The single race-recheck step that fires +/// only under a real concurrent dispose is isolated in DisposeIfRaced and excluded from +/// coverage there. +public class DisposableSlotHelperTests +{ + /// Verifies that an incoming value is disposed immediately if the slot is already disposed. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAssignWithoutDisposingPreviousIntoDisposedSlot_ThenIncomingDisposed() + { + IDisposable? slot = null; + var disposed = DisposableSlotHelper.DisposedSentinel; + var late = new CountingDisposable(); + + DisposableSlotHelper.AssignWithoutDisposingPrevious(ref slot, ref disposed, late); + + await Assert.That(late.DisposeCount).IsEqualTo(1); + await Assert.That(slot).IsNull(); + } + + /// Verifies the steady-state assign — slot transitions to the new value without disposing the previous. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAssignWithoutDisposingPreviousOpen_ThenStoresAndLeavesPreviousAlone() + { + var first = new CountingDisposable(); + IDisposable? slot = first; + var disposed = 0; + var second = new CountingDisposable(); + + DisposableSlotHelper.AssignWithoutDisposingPrevious(ref slot, ref disposed, second); + + await Assert.That(slot).IsSameReferenceAs(second); + await Assert.That(first.DisposeCount).IsEqualTo(0); + } + + /// Verifies that assigning a null value into an open slot stores null without throwing. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAssignWithoutDisposingPreviousNullValueOpen_ThenStoresNull() + { + var first = new CountingDisposable(); + IDisposable? slot = first; + var disposed = 0; + + DisposableSlotHelper.AssignWithoutDisposingPrevious(ref slot, ref disposed, null); + + await Assert.That(slot).IsNull(); + await Assert.That(first.DisposeCount).IsEqualTo(0); + } + + /// Verifies the swap path disposes the previous value on each assignment. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSwapAndDisposePreviousOpen_ThenPreviousDisposed() + { + var first = new CountingDisposable(); + IDisposable? slot = first; + var disposed = 0; + var second = new CountingDisposable(); + + DisposableSlotHelper.SwapAndDisposePrevious(ref slot, ref disposed, second); + + await Assert.That(slot).IsSameReferenceAs(second); + await Assert.That(first.DisposeCount).IsEqualTo(1); + await Assert.That(second.DisposeCount).IsEqualTo(0); + } + + /// Verifies the swap path disposes the incoming value if the slot is already disposed. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSwapAndDisposePreviousIntoDisposedSlot_ThenIncomingDisposed() + { + IDisposable? slot = null; + var disposed = DisposableSlotHelper.DisposedSentinel; + var late = new CountingDisposable(); + + DisposableSlotHelper.SwapAndDisposePrevious(ref slot, ref disposed, late); + + await Assert.That(late.DisposeCount).IsEqualTo(1); + } + + /// Verifies TryDispose latches and disposes the inner on the first call. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTryDisposeOpen_ThenLatchesAndDisposesInner() + { + var inner = new CountingDisposable(); + IDisposable? slot = inner; + var disposed = 0; + + var first = DisposableSlotHelper.TryDispose(ref slot, ref disposed); + var second = DisposableSlotHelper.TryDispose(ref slot, ref disposed); + + await Assert.That(first).IsTrue(); + await Assert.That(second).IsFalse(); + await Assert.That(inner.DisposeCount).IsEqualTo(1); + } + + /// Disposable used to verify dispose counts. + private sealed class CountingDisposable : IDisposable + { + /// Gets the number of times has been invoked. + public int DisposeCount { get; private set; } + + /// + public void Dispose() => DisposeCount++; + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Internal/FirstAsTaskHelperTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Internal/FirstAsTaskHelperTests.cs index cc2eb37..f7d5585 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Internal/FirstAsTaskHelperTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Internal/FirstAsTaskHelperTests.cs @@ -52,4 +52,95 @@ public async Task WhenSourceEmitsMultiple_ThenTaskCompletesWithFirst() await Assert.That(await task).IsEqualTo(FirstValue); } + + /// Verifies the helper throws when the source argument is null. + [Test] + public void WhenSourceNull_ThenThrowsArgumentNullException() => + Assert.Throws(static () => FirstAsTaskHelper.FirstAsTask(null!)); + + /// Verifies emissions arriving after the task has already settled are silently ignored. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubjectErrorsThenLaterEvents_ThenLaterEventsIgnored() + { + var subject = new Subject(); + var task = FirstAsTaskHelper.FirstAsTask(subject); + var expected = new InvalidOperationException("first"); + + subject.OnError(expected); + subject.OnCompleted(); + subject.OnNext(FirstValue); + + var ex = await Assert.ThrowsAsync(async () => await task); + await Assert.That(ex).IsSameReferenceAs(expected); + } + + /// Verifies that a second OnNext arriving via a non-cooperative source + /// (one that does not stop emitting after the first value) is dropped by the latch. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSecondOnNextAfterFirstSettled_ThenIgnored() + { + var source = new InvasiveObservable(); + var task = FirstAsTaskHelper.FirstAsTask(source); + + source.Observer.OnNext(FirstValue); + source.Observer.OnNext(SecondValue); + source.Observer.OnError(new InvalidOperationException("ignored")); + source.Observer.OnCompleted(); + + await Assert.That(await task).IsEqualTo(FirstValue); + } + + /// Verifies that a second OnError arriving after the first is dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSecondOnErrorAfterFirstSettled_ThenIgnored() + { + var source = new InvasiveObservable(); + var task = FirstAsTaskHelper.FirstAsTask(source); + var expected = new InvalidOperationException("first"); + + source.Observer.OnError(expected); + source.Observer.OnError(new InvalidOperationException("ignored")); + source.Observer.OnCompleted(); + + var caught = await Assert.ThrowsAsync(async () => await task); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that a second OnCompleted arriving after the first is dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSecondOnCompletedAfterFirstSettled_ThenIgnored() + { + var source = new InvasiveObservable(); + var task = FirstAsTaskHelper.FirstAsTask(source); + + source.Observer.OnCompleted(); + source.Observer.OnCompleted(); + source.Observer.OnError(new InvalidOperationException("ignored")); + + await Assert.ThrowsAsync(async () => await task); + } + + /// Test observable that captures its subscriber so tests can directly invoke + /// non-cooperative double-terminal sequences against FirstAsTaskHelper's observer. + /// The element type. + private sealed class InvasiveObservable : IObservable + { + /// The captured observer from the most recent subscription. + private IObserver? _observer; + + /// Gets the captured observer. + public IObserver Observer => _observer + ?? throw new InvalidOperationException("No subscriber yet."); + + /// + public IDisposable Subscribe(IObserver observer) + { + _observer = observer; + return System.Reactive.Disposables.Disposable.Empty; + } + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Internal/MutableDisposableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Internal/MutableDisposableTests.cs new file mode 100644 index 0000000..fec77a5 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Internal/MutableDisposableTests.cs @@ -0,0 +1,74 @@ +// 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.Extensions.Internal.Disposables; + +namespace ReactiveUI.Extensions.Tests.Internal; + +/// Tests for — verifies that reassigning the inner does +/// NOT dispose the previous, that assigning after disposal immediately disposes the incoming +/// value, and that Dispose is idempotent. +public class MutableDisposableTests +{ + /// Verifies replacement leaves the previous inner alone. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenInnerReplaced_ThenPreviousIsNotDisposed() + { + using var holder = new MutableDisposable(); + var firstDisposed = 0; + var secondDisposed = 0; + var first = new ActionDisposable(() => firstDisposed++); + var second = new ActionDisposable(() => secondDisposed++); + + holder.Disposable = first; + holder.Disposable = second; + + await Assert.That(firstDisposed).IsEqualTo(0); + await Assert.That(holder.Disposable).IsSameReferenceAs(second); + await Assert.That(secondDisposed).IsEqualTo(0); + } + + /// Verifies that assigning after disposal immediately disposes the incoming value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSetAfterDispose_ThenIncomingIsDisposedImmediately() + { + var holder = new MutableDisposable(); + holder.Dispose(); + var late = 0; + + holder.Disposable = new ActionDisposable(() => late++); + + await Assert.That(late).IsEqualTo(1); + } + + /// Verifies that assigning after disposal is a no-op. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSetNullAfterDispose_ThenNoThrow() + { + var holder = new MutableDisposable(); + holder.Dispose(); + + holder.Disposable = null; + + await Assert.That(holder.Disposable).IsNull(); + } + + /// Verifies Dispose disposes the inner and is idempotent across repeated calls. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDisposedTwice_ThenInnerDisposedOnce() + { + var holder = new MutableDisposable(); + var disposed = 0; + holder.Disposable = new ActionDisposable(() => disposed++); + + holder.Dispose(); + holder.Dispose(); + + await Assert.That(disposed).IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Internal/ObserverArrayHelpersTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Internal/ObserverArrayHelpersTests.cs new file mode 100644 index 0000000..b4f9025 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Internal/ObserverArrayHelpersTests.cs @@ -0,0 +1,163 @@ +// 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.Extensions.Internal; + +namespace ReactiveUI.Extensions.Tests.Internal; + +/// Direct unit tests for — both the broadcast +/// loop and the remove-or-null short-circuit paths. The helpers are pure functions over +/// their inputs, so each branch is exercised by passing synthesized arrays rather than +/// relying on operator-level scheduler races. +public class ObserverArrayHelpersTests +{ + /// Sentinel value broadcast through the helper. + private const int Sentinel = 7; + + /// Expected length of the array after removing one observer from three. + private const int RemainingLengthAfterRemoveFromThree = 2; + + /// Verifies short-circuits when + /// the observer array is empty. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenBroadcastEmpty_ThenNoOp() + { + var observers = Array.Empty>(); + + ObserverArrayHelpers.Broadcast(observers, Sentinel); + + await Assert.That(observers).IsEmpty(); + } + + /// Verifies fans the value out to + /// every observer in order. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenBroadcastMultiple_ThenEveryObserverReceivesValue() + { + var first = new RecordingObserver(); + var second = new RecordingObserver(); + var third = new RecordingObserver(); + IObserver[] observers = [first, second, third]; + + ObserverArrayHelpers.Broadcast(observers, Sentinel); + + await Assert.That(first.Values).IsCollectionEqualTo([Sentinel]); + await Assert.That(second.Values).IsCollectionEqualTo([Sentinel]); + await Assert.That(third.Values).IsCollectionEqualTo([Sentinel]); + } + + /// Verifies returns + /// when the observer is not present in the array. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRemoveNotPresent_ThenReturnsNull() + { + var empty = Array.Empty>(); + var resident = new RecordingObserver(); + var stranger = new RecordingObserver(); + IObserver[] current = [resident]; + + var result = ObserverArrayHelpers.RemoveOrNull(current, stranger, empty); + + await Assert.That(result).IsNull(); + } + + /// Verifies returns the empty + /// sentinel when the array contains exactly one observer (the one being removed). + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRemoveSingleton_ThenReturnsEmptySentinel() + { + var empty = Array.Empty>(); + var only = new RecordingObserver(); + IObserver[] current = [only]; + + var result = ObserverArrayHelpers.RemoveOrNull(current, only, empty); + + await Assert.That(result).IsSameReferenceAs(empty); + } + + /// Verifies removes the first + /// observer from a multi-element array (no left copy, full right copy). + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRemoveFirstFromThree_ThenLeavesTrailingPair() + { + var empty = Array.Empty>(); + var a = new RecordingObserver(); + var b = new RecordingObserver(); + var c = new RecordingObserver(); + IObserver[] current = [a, b, c]; + + var result = ObserverArrayHelpers.RemoveOrNull(current, a, empty); + + await Assert.That(result).IsNotNull(); + await Assert.That(result!.Length).IsEqualTo(RemainingLengthAfterRemoveFromThree); + await Assert.That(ReferenceEquals(result[0], b)).IsTrue(); + await Assert.That(ReferenceEquals(result[1], c)).IsTrue(); + } + + /// Verifies removes the middle + /// observer (both left and right copies non-empty). + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRemoveMiddleFromThree_ThenLeavesFirstAndLast() + { + var empty = Array.Empty>(); + var a = new RecordingObserver(); + var b = new RecordingObserver(); + var c = new RecordingObserver(); + IObserver[] current = [a, b, c]; + + var result = ObserverArrayHelpers.RemoveOrNull(current, b, empty); + + await Assert.That(result).IsNotNull(); + await Assert.That(result!.Length).IsEqualTo(RemainingLengthAfterRemoveFromThree); + await Assert.That(ReferenceEquals(result[0], a)).IsTrue(); + await Assert.That(ReferenceEquals(result[1], c)).IsTrue(); + } + + /// Verifies removes the last + /// observer (full left copy, no right copy). + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRemoveLastFromThree_ThenLeavesLeadingPair() + { + var empty = Array.Empty>(); + var a = new RecordingObserver(); + var b = new RecordingObserver(); + var c = new RecordingObserver(); + IObserver[] current = [a, b, c]; + + var result = ObserverArrayHelpers.RemoveOrNull(current, c, empty); + + await Assert.That(result).IsNotNull(); + await Assert.That(result!.Length).IsEqualTo(RemainingLengthAfterRemoveFromThree); + await Assert.That(ReferenceEquals(result[0], a)).IsTrue(); + await Assert.That(ReferenceEquals(result[1], b)).IsTrue(); + } + + /// Recording observer used to verify Broadcast reaches each slot. + /// The element type. + private sealed class RecordingObserver : IObserver + { + /// Gets the captured OnNext values in order. + public List Values { get; } = []; + + /// + public void OnNext(T value) => Values.Add(value); + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Internal/SwapDisposableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Internal/SwapDisposableTests.cs new file mode 100644 index 0000000..78d3941 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Internal/SwapDisposableTests.cs @@ -0,0 +1,70 @@ +// 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.Extensions.Internal.Disposables; + +namespace ReactiveUI.Extensions.Tests.Internal; + +/// Tests for — verifies replacement disposes the previous +/// inner, assigning after disposal immediately disposes the incoming value, and Dispose +/// is idempotent. +public class SwapDisposableTests +{ + /// Verifies that replacement disposes the previous inner. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenInnerReplaced_ThenPreviousIsDisposed() + { + using var holder = new SwapDisposable(); + var firstDisposed = 0; + var secondDisposed = 0; + holder.Disposable = new ActionDisposable(() => firstDisposed++); + holder.Disposable = new ActionDisposable(() => secondDisposed++); + + await Assert.That(firstDisposed).IsEqualTo(1); + await Assert.That(secondDisposed).IsEqualTo(0); + } + + /// Verifies that the getter returns the currently-assigned inner. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenGetCurrent_ThenReturnsCurrent() + { + using var holder = new SwapDisposable(); + var current = new ActionDisposable(static () => { }); + + holder.Disposable = current; + + await Assert.That(holder.Disposable).IsSameReferenceAs(current); + } + + /// Verifies that assigning after disposal immediately disposes the incoming value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSetAfterDispose_ThenIncomingIsDisposedImmediately() + { + var holder = new SwapDisposable(); + holder.Dispose(); + var late = 0; + + holder.Disposable = new ActionDisposable(() => late++); + + await Assert.That(late).IsEqualTo(1); + } + + /// Verifies Dispose is idempotent across repeated calls. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDisposedTwice_ThenInnerDisposedOnce() + { + var holder = new SwapDisposable(); + var disposed = 0; + holder.Disposable = new ActionDisposable(() => disposed++); + + holder.Dispose(); + holder.Dispose(); + + await Assert.That(disposed).IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/ObservableSubscriptionExtensionsTests.cs b/src/tests/ReactiveUI.Extensions.Tests/ObservableSubscriptionExtensionsTests.cs index 521edcb..30ad751 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/ObservableSubscriptionExtensionsTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/ObservableSubscriptionExtensionsTests.cs @@ -158,4 +158,66 @@ public async Task WhenWaitForErrorTimesOut_ThenTimeoutException() var ex = Assert.Throws(call); await Assert.That(ex).IsNotNull(); } + + /// Verifies the single-arg WaitForCompletion(IObservable<Unit>) overload — + /// pass-through to the scheduler-aware core with default timeout. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitForCompletionUnitDefault_ThenReturnsOnCompletion() + { + var subject = new Subject(); + var pump = Task.Run(() => + { + subject.OnNext(Unit.Default); + subject.OnCompleted(); + }); + + subject.WaitForCompletion(); + await pump; + } + + /// Exercises the no-op OnError body of ValueCaptureObserver — + /// SubscribeGetValue on an erroring source still returns the last captured value + /// (default) and the error is silently swallowed by the observer. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscribeGetValueSourceErrors_ThenErrorSwallowed() + { + var error = new InvalidOperationException("source-error"); + var source = Observable.Throw(error); + + var value = source.SubscribeGetValue(); + + await Assert.That(value).IsEqualTo(0); + } + + /// Exercises the no-op OnNext and OnCompleted bodies of + /// ErrorCaptureObserverSubscribeGetError on a completing source ignores + /// the value and the completion, returning a null error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscribeGetErrorSourceCompletesWithValue_ThenReturnsNull() + { + IObservable source = Observable.Return(SentinelValue); + + var error = source.SubscribeGetError(); + + await Assert.That(error).IsNull(); + } + + /// Exercises the OnError path of BlockingValueObserver — + /// WaitForValue on an erroring source returns the default value once the gate + /// is signalled by the error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitForValueSourceErrors_ThenGateSignalledAndDefaultReturned() + { + var subject = new Subject(); + var pump = Task.Run(() => subject.OnError(new InvalidOperationException("source-error"))); + + var value = subject.WaitForValue(); + await pump; + + await Assert.That(value).IsEqualTo(0); + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/BooleanReduceObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/BooleanReduceObservableTests.cs index 57c209d..66ab866 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Operators/BooleanReduceObservableTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/BooleanReduceObservableTests.cs @@ -105,4 +105,48 @@ public async Task WhenAllTrueSourceErrors_ThenForwardsError() await Assert.That(caught).IsSameReferenceAs(expected); } + + /// Verifies that when every source completes, the combined sequence completes via + /// the per-source OnCompleted path. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAllSourcesComplete_ThenForwardsCompletion() + { + var a = new Subject(); + var b = new Subject(); + var completed = false; + IObservable[] sources = [a, b]; + + using var sub = sources.CombineLatestValuesAreAllTrue().Subscribe(static _ => { }, () => completed = true); + + a.OnNext(true); + b.OnNext(true); + a.OnCompleted(); + b.OnCompleted(); + + await Assert.That(completed).IsTrue(); + } + + /// Verifies that an OnNext arriving after the combined sequence has terminated + /// is silently dropped via the _state.IsDone guard. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnNextAfterTerminated_ThenDropped() + { + var a = new SyncDirectSource(); + var b = new SyncDirectSource(); + var results = new List(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + IObservable[] sources = [a, b]; + + using var sub = sources.CombineLatestValuesAreAllTrue() + .Subscribe(results.Add, ex => caught = ex); + + a.Observer.OnError(expected); + b.Observer.OnNext(true); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(results).IsEmpty(); + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/ConflateObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/ConflateObservableTests.cs index 42fd61c..b0cb5c6 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Operators/ConflateObservableTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/ConflateObservableTests.cs @@ -18,6 +18,9 @@ public class ConflateObservableTests /// Minimum-update-period tick window for the conflate operator. private const int UpdatePeriodTicks = 100; + /// Multiplier used to advance past the update period in settle assertions. + private const int SettleMultiplier = 2; + /// Half of the update-period window. private const int HalfWindowTicks = 50; @@ -115,4 +118,147 @@ public async Task WhenConflateDisposedBeforeScheduledEmission_ThenSuppressed() scheduler.AdvanceBy(UpdatePeriodTicks); await Assert.That(results.Count).IsEqualTo(snapshot); } + + /// Verifies that an OnNext arriving after the source has completed is silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnNextAfterCompleted_ThenDropped() + { + var scheduler = new TestScheduler(); + var source = new SyncDirectSource(); + var results = new List(); + var completed = false; + + using var sub = source.Conflate(TimeSpan.FromTicks(UpdatePeriodTicks), scheduler) + .Subscribe(results.Add, () => completed = true); + + source.Observer.OnCompleted(); + scheduler.AdvanceBy(SettleMultiplier * UpdatePeriodTicks); + source.Observer.OnNext(1); + scheduler.AdvanceBy(SettleMultiplier * UpdatePeriodTicks); + + await Assert.That(completed).IsTrue(); + await Assert.That(results).IsEmpty(); + } + + /// Verifies that an OnError arriving after completion is silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnErrorAfterCompleted_ThenDropped() + { + var scheduler = new TestScheduler(); + var source = new SyncDirectSource(); + Exception? caught = null; + var completed = false; + + using var sub = source.Conflate(TimeSpan.FromTicks(UpdatePeriodTicks), scheduler) + .Subscribe(static _ => { }, ex => caught = ex, () => completed = true); + + source.Observer.OnCompleted(); + scheduler.AdvanceBy(UpdatePeriodTicks); + source.Observer.OnError(new InvalidOperationException("late")); + scheduler.AdvanceBy(UpdatePeriodTicks); + + await Assert.That(completed).IsTrue(); + await Assert.That(caught).IsNull(); + } + + /// Verifies that a duplicate OnCompleted after an error is silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnCompletedAfterError_ThenDropped() + { + var scheduler = new TestScheduler(); + var source = new SyncDirectSource(); + Exception? caught = null; + var completed = false; + var expected = new InvalidOperationException("first"); + + using var sub = source.Conflate(TimeSpan.FromTicks(UpdatePeriodTicks), scheduler) + .Subscribe(static _ => { }, ex => caught = ex, () => completed = true); + + source.Observer.OnError(expected); + scheduler.AdvanceBy(UpdatePeriodTicks); + source.Observer.OnCompleted(); + scheduler.AdvanceBy(UpdatePeriodTicks); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(completed).IsFalse(); + } + + /// Verifies 's + /// post-dispose Enqueue guard by constructing the marshaller directly, disposing it, + /// and then pushing a notification — exercising the defensive branch that is otherwise + /// unreachable through the front-door Conflate pipeline. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMarshallerEnqueuedAfterDispose_ThenSilentlyDropped() + { + var downstream = new RecordingObserver(); + var scheduler = new TestScheduler(); + var marshaller = new ReactiveUI.Extensions.Operators.ConflateObservable.SchedulerMarshaller( + downstream, + scheduler); + + marshaller.Dispose(); + marshaller.OnNext(1); + marshaller.OnError(new InvalidOperationException("late")); + marshaller.OnCompleted(); + scheduler.AdvanceBy(UpdatePeriodTicks); + + await Assert.That(downstream.Values).IsEmpty(); + await Assert.That(downstream.Error).IsNull(); + await Assert.That(downstream.Completed).IsFalse(); + } + + /// Verifies 's + /// after-terminal guards on OnNext, OnError, and OnCompleted by constructing + /// the sink directly, terminating via OnError, and then pushing follow-up notifications. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSinkEventsAfterTerminated_ThenDropped() + { + var downstream = new RecordingObserver(); + var scheduler = new TestScheduler(); + var sink = new ReactiveUI.Extensions.Operators.ConflateObservable.ConflateSink( + downstream, + TimeSpan.FromTicks(UpdatePeriodTicks), + scheduler); + + var expected = new InvalidOperationException("first"); + sink.OnError(expected); + + sink.OnNext(1); + sink.OnError(new InvalidOperationException("ignored")); + sink.OnCompleted(); + + await Assert.That(downstream.Error).IsSameReferenceAs(expected); + await Assert.That(downstream.Values).IsEmpty(); + await Assert.That(downstream.Completed).IsFalse(); + } + + /// Recording observer used to verify direct-invocation tests of the conflate sink + /// and marshaller — does not race with a scheduler, so the assertion sees exactly the + /// notifications that were forwarded. + /// The element type. + private sealed class RecordingObserver : IObserver + { + /// Gets the captured OnNext values in order. + public List Values { get; } = []; + + /// Gets the first captured OnError exception, if any. + public Exception? Error { get; private set; } + + /// Gets a value indicating whether OnCompleted has been called. + public bool Completed { get; private set; } + + /// + public void OnNext(T value) => Values.Add(value); + + /// + public void OnError(Exception error) => Error ??= error; + + /// + public void OnCompleted() => Completed = true; + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/DebounceImmediateObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/DebounceImmediateObservableTests.cs new file mode 100644 index 0000000..55af7fa --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/DebounceImmediateObservableTests.cs @@ -0,0 +1,81 @@ +// 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 Microsoft.Reactive.Testing; + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// Tests for DebounceImmediateObservable covering the after-terminal guards +/// on the sink that fire only when an upstream pushes events past its own completion. +public class DebounceImmediateObservableTests +{ + /// Tick window for the debounce. + private const int DebounceTicks = 10; + + /// Ticks to advance past the debounce window in settle assertions. + private const int SettleTicks = 100; + + /// Verifies that OnNext after the source has completed is silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnNextAfterCompleted_ThenDropped() + { + var scheduler = new TestScheduler(); + var source = new SyncDirectSource(); + var values = new List(); + var completed = false; + + using var sub = source.DebounceImmediate(TimeSpan.FromTicks(DebounceTicks), scheduler) + .Subscribe(values.Add, () => completed = true); + + source.Observer.OnCompleted(); + scheduler.AdvanceBy(SettleTicks); + source.Observer.OnNext(1); + scheduler.AdvanceBy(SettleTicks); + + await Assert.That(completed).IsTrue(); + await Assert.That(values).IsEmpty(); + } + + /// Verifies that OnError after the source has completed is silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnErrorAfterCompleted_ThenDropped() + { + var scheduler = new TestScheduler(); + var source = new SyncDirectSource(); + Exception? caught = null; + var completed = false; + + using var sub = source.DebounceImmediate(TimeSpan.FromTicks(DebounceTicks), scheduler) + .Subscribe(static _ => { }, ex => caught = ex, () => completed = true); + + source.Observer.OnCompleted(); + source.Observer.OnError(new InvalidOperationException("late")); + + await Assert.That(completed).IsTrue(); + await Assert.That(caught).IsNull(); + } + + /// Verifies that a duplicate OnCompleted after an error is silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnCompletedAfterError_ThenDropped() + { + var scheduler = new TestScheduler(); + var source = new SyncDirectSource(); + Exception? caught = null; + var completed = false; + var expected = new InvalidOperationException("first"); + + using var sub = source.DebounceImmediate(TimeSpan.FromTicks(DebounceTicks), scheduler) + .Subscribe(static _ => { }, ex => caught = ex, () => completed = true); + + source.Observer.OnError(expected); + source.Observer.OnCompleted(); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(completed).IsFalse(); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/FirstMatchFromCandidatesAsyncPathTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/FirstMatchFromCandidatesAsyncPathTests.cs new file mode 100644 index 0000000..e0e86ff --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/FirstMatchFromCandidatesAsyncPathTests.cs @@ -0,0 +1,275 @@ +// 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. + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// Coverage for the asynchronous-projection path of +/// FirstMatchFromCandidates backed by FirstMatchFromCandidatesObservable +/// — empty candidate list, async-projection match, async-projection no-match falls back, +/// async-projection error skips, and dispose during the async walk. +public class FirstMatchFromCandidatesAsyncPathTests +{ + /// Fallback value emitted when no candidate matches. + private const string Fallback = "fallback"; + + /// Verifies that an empty candidate list emits the fallback and completes. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCandidatesEmpty_ThenEmitsFallbackAndCompletes() + { + var results = new List(); + var completed = false; + + using var sub = Array.Empty() + .FirstMatchFromCandidates( + static _ => Observable.Empty(), + static raw => raw, + static value => value.Length > 0, + Fallback) + .Subscribe(results.Add, () => completed = true); + + await Assert.That(results).IsCollectionEqualTo([Fallback]); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that an async projection whose value matches the predicate emits the + /// matching value and completes — exercises the AsyncSink.OnNext match path. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAsyncProjectionMatches_ThenEmitsMatch() + { + string[] keys = ["miss", "hit"]; + var emissionGate = new Subject(); + var results = new List(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = ((IReadOnlyList)keys) + .FirstMatchFromCandidates( + key => key == "hit" ? emissionGate : Observable.Empty(), + static raw => raw, + static value => value == "hit", + Fallback) + .Subscribe(results.Add, () => completed.TrySetResult(true)); + + emissionGate.OnNext("hit"); + emissionGate.OnCompleted(); + + var done = await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(done).IsTrue(); + await Assert.That(results).IsCollectionEqualTo(["hit"]); + } + + /// Verifies that an async projection that never matches falls through to the + /// fallback when its source completes — exercises the async OnCompleted path. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAsyncProjectionNeverMatches_ThenFallback() + { + string[] keys = ["only"]; + var subject = new Subject(); + var results = new List(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = ((IReadOnlyList)keys) + .FirstMatchFromCandidates( + _ => subject, + static raw => raw, + static value => value == "match-impossible", + Fallback) + .Subscribe(results.Add, () => completed.TrySetResult(true)); + + subject.OnNext("nope"); + subject.OnCompleted(); + + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(results).IsCollectionEqualTo([Fallback]); + } + + /// Verifies that an async projection error is swallowed and the walk continues + /// to the next candidate — exercises the async OnError path. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAsyncProjectionErrors_ThenSkipsToNextCandidate() + { + string[] keys = ["bad", "good"]; + var badSubject = new Subject(); + var goodSubject = new Subject(); + var results = new List(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = ((IReadOnlyList)keys) + .FirstMatchFromCandidates( + key => key == "bad" ? badSubject : goodSubject, + static raw => raw, + static value => value == "good", + Fallback) + .Subscribe(results.Add, () => completed.TrySetResult(true)); + + badSubject.OnError(new InvalidOperationException("bad failed")); + goodSubject.OnNext("good"); + goodSubject.OnCompleted(); + + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(results).IsCollectionEqualTo(["good"]); + } + + /// Verifies that disposing during the async walk stops further candidate processing. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDisposedDuringAsyncWalk_ThenStops() + { + string[] keys = ["k1", "k2"]; + var firstSubject = new Subject(); + var results = new List(); + var completed = false; + + var sub = ((IReadOnlyList)keys) + .FirstMatchFromCandidates( + _ => firstSubject, + static raw => raw, + static _ => true, + Fallback) + .Subscribe(results.Add, () => completed = true); + + sub.Dispose(); + firstSubject.OnNext("late"); + firstSubject.OnCompleted(); + + await Assert.That(results).IsEmpty(); + await Assert.That(completed).IsFalse(); + } + + /// Verifies that when the synchronous transform throws for one candidate the next + /// candidate is tried — exercises the catch { continue; } path in the sync fast path. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSyncTransformThrows_ThenContinuesToNextCandidate() + { + string[] keys = ["throw", "hit"]; + var results = new List(); + var completed = false; + + using var sub = ((IReadOnlyList)keys) + .FirstMatchFromCandidates( + static key => Observable.Return(key), + static raw => raw == "throw" + ? throw new InvalidOperationException("transform-throws") + : raw, + static value => value == "hit", + Fallback) + .Subscribe(results.Add, () => completed = true); + + await Assert.That(results).IsCollectionEqualTo(["hit"]); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that a candidate whose projected observable synchronously calls + /// OnError on the sink during its Subscribe call hits the + /// if (_looping) return; re-entrancy guard in AsyncSink.OnError and + /// proceeds to the next candidate. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAsyncCandidateProjectionSyncErrors_ThenLoopingGuardSkipsToNextCandidate() + { + string[] keys = ["sync-error", "hit"]; + var results = new List(); + var completed = false; + + using var sub = ((IReadOnlyList)keys) + .FirstMatchFromCandidates( + key => key == "sync-error" + ? new SyncErroringObservable(new InvalidOperationException("sync-error")) + : Observable.Return(key), + static raw => raw, + static value => value == "hit", + Fallback) + .Subscribe(results.Add, () => completed = true); + + await Assert.That(results).IsCollectionEqualTo(["hit"]); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that a candidate whose projected observable synchronously calls + /// OnCompleted on the sink during its Subscribe call hits the + /// if (_looping) return; re-entrancy guard in AsyncSink.OnCompleted and + /// proceeds to the next candidate. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAsyncCandidateProjectionSyncCompletes_ThenLoopingGuardSkipsToNextCandidate() + { + string[] keys = ["sync-complete", "hit"]; + var results = new List(); + var completed = false; + + using var sub = ((IReadOnlyList)keys) + .FirstMatchFromCandidates( + key => key == "sync-complete" + ? new SyncCompletingObservable() + : Observable.Return(key), + static raw => raw, + static value => value == "hit", + Fallback) + .Subscribe(results.Add, () => completed = true); + + await Assert.That(results).IsCollectionEqualTo(["hit"]); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that a second async candidate emission arriving after a match has already + /// fired is silently dropped via the _done guard in AsyncSink.OnNext. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAsyncCandidateEmitsAfterMatch_ThenDroppedByDoneGuard() + { + string[] keys = ["hit"]; + var subject = new Subject(); + var results = new List(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = ((IReadOnlyList)keys) + .FirstMatchFromCandidates( + _ => subject, + static raw => raw, + static value => value == "hit", + Fallback) + .Subscribe(results.Add, () => completed.TrySetResult()); + + subject.OnNext("hit"); + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + subject.OnNext("ignored-late"); + subject.OnError(new InvalidOperationException("ignored-late")); + subject.OnCompleted(); + + await Assert.That(results).IsCollectionEqualTo(["hit"]); + } + + /// Observable that synchronously calls OnError on the subscriber from inside + /// its Subscribe method — used to exercise the re-entrancy _looping guard. + /// The element type. + /// The exception to deliver to the subscriber. + private sealed class SyncErroringObservable(Exception error) : IObservable + { + /// + public IDisposable Subscribe(IObserver observer) + { + observer.OnError(error); + return System.Reactive.Disposables.Disposable.Empty; + } + } + + /// Observable that synchronously calls OnCompleted on the subscriber from + /// inside its Subscribe method — used to exercise the re-entrancy _looping + /// guard. + /// The element type. + private sealed class SyncCompletingObservable : IObservable + { + /// + public IDisposable Subscribe(IObserver observer) + { + observer.OnCompleted(); + return System.Reactive.Disposables.Disposable.Empty; + } + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/ForEachObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/ForEachObservableTests.cs new file mode 100644 index 0000000..7aa27ea --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/ForEachObservableTests.cs @@ -0,0 +1,88 @@ +// 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.Extensions.Operators; + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// Tests for — null-batch ignore semantics, the +/// scheduler-marshalled delivery path, error forwarding, and the null-observer subscribe guard. +public class ForEachObservableTests +{ + /// Sentinel batch element. + private const int ValueOne = 1; + + /// Sentinel batch element. + private const int ValueTwo = 2; + + /// Sentinel batch element. + private const int ValueThree = 3; + + /// Scheduler-delivered sentinel. + private const int ScheduledTen = 10; + + /// Scheduler-delivered sentinel. + private const int ScheduledTwenty = 20; + + /// Scheduler-delivered sentinel. + private const int ScheduledThirty = 30; + + /// Verifies that a null inner enumerable is ignored and subsequent batches continue flowing. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenForEachReceivesNullBatch_ThenIgnoresNullAndProcessesNext() + { + var subject = new Subject>(); + var results = new List(); + using var sub = subject.ForEach().Subscribe(results.Add); + + subject.OnNext(null!); + subject.OnNext([ValueOne, ValueTwo]); + subject.OnNext([ValueThree]); + + await Assert.That(results).IsCollectionEqualTo([ValueOne, ValueTwo, ValueThree]); + } + + /// Verifies the scheduler overload delivers every value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenForEachWithScheduler_ThenDeliversAllValues() + { + IEnumerable[] batches = [[ScheduledTen, ScheduledTwenty], [ScheduledThirty]]; + var source = batches.ToObservable(); + var done = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + var results = new List(); + + using var sub = source.ForEach(Scheduler.Default).Subscribe( + results.Add, + () => done.TrySetResult(results)); + + var output = await done.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(output).IsCollectionEqualTo([ScheduledTen, ScheduledTwenty, ScheduledThirty]); + } + + /// Verifies source errors are forwarded. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenForEachSourceErrors_ThenErrorForwarded() + { + var subject = new Subject>(); + Exception? caught = null; + using var sub = subject.ForEach().Subscribe(static _ => { }, ex => caught = ex); + var expected = new InvalidOperationException("boom"); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies subscribing with a null observer throws. + [Test] + public void WhenForEachObserverNull_ThenSubscribeThrows() + { + var observable = new ForEachObservable(new Subject>(), null); + + Assert.Throws(() => observable.Subscribe(null!)); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/HeartbeatObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/HeartbeatObservableTests.cs index e84148d..cd43a00 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Operators/HeartbeatObservableTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/HeartbeatObservableTests.cs @@ -108,4 +108,40 @@ public async Task WhenHeartbeatSourceEmits_ThenForwardsValueUpdate() await Assert.That(updates).IsCollectionEqualTo([Value]); } + + /// Verifies that OnNext, OnError and a duplicate OnCompleted + /// arriving after the source has already completed are silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenEventsAfterCompleted_ThenDropped() + { + var scheduler = new TestScheduler(); + var source = new SyncDirectSource(); + var values = new List(); + Exception? caught = null; + var completedCount = 0; + + using var sub = source.Heartbeat(TimeSpan.FromTicks(HeartbeatTicks), scheduler) + .Subscribe( + hb => + { + if (hb.IsHeartbeat) + { + return; + } + + values.Add(hb.Update); + }, + ex => caught = ex, + () => completedCount++); + + source.Observer.OnCompleted(); + source.Observer.OnNext(1); + source.Observer.OnError(new InvalidOperationException("late")); + source.Observer.OnCompleted(); + + await Assert.That(completedCount).IsEqualTo(1); + await Assert.That(values).IsEmpty(); + await Assert.That(caught).IsNull(); + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/LogErrorsObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/LogErrorsObservableTests.cs new file mode 100644 index 0000000..43f873a --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/LogErrorsObservableTests.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 ReactiveUI.Extensions.Operators; + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// Tests for — verifies the logger is tapped on +/// the error path, never on the success path, and that the null-observer subscribe guard fires. +public class LogErrorsObservableTests +{ + /// Sentinel value flowing through the success path. + private const int Sentinel = 1; + + /// Verifies the logger is invoked with the source error and the error is forwarded downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLogErrorsSourceErrors_ThenLoggerInvokedAndErrorForwarded() + { + var subject = new Subject(); + Exception? logged = null; + Exception? caught = null; + var values = new List(); + var expected = new InvalidOperationException("logged"); + + using var sub = subject.LogErrors(ex => logged = ex).Subscribe(values.Add, ex => caught = ex); + + subject.OnNext(Sentinel); + subject.OnError(expected); + + await Assert.That(values).IsCollectionEqualTo([Sentinel]); + await Assert.That(logged).IsSameReferenceAs(expected); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies completion is forwarded without invoking the logger. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLogErrorsSourceCompletes_ThenLoggerNotInvoked() + { + var subject = new Subject(); + var logged = 0; + var completed = false; + + using var sub = subject.LogErrors(_ => logged++).Subscribe(static _ => { }, () => completed = true); + + subject.OnCompleted(); + + await Assert.That(logged).IsEqualTo(0); + await Assert.That(completed).IsTrue(); + } + + /// Verifies subscribing with a null observer throws. + [Test] + public void WhenLogErrorsObserverNull_ThenSubscribeThrows() + { + var observable = new LogErrorsObservable(new Subject(), static _ => { }); + + Assert.Throws(() => observable.Subscribe(null!)); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/MinMaxObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/MinMaxObservableTests.cs index 7747a46..dbb0d07 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Operators/MinMaxObservableTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/MinMaxObservableTests.cs @@ -90,4 +90,87 @@ public async Task WhenGetMaxSourceErrors_ThenForwardsError() await Assert.That(caught).IsSameReferenceAs(expected); } + + /// Verifies GetMax with no additional sources still emits the source's own values verbatim. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenGetMaxSingleSource_ThenEmitsSourceValues() + { + var subject = new Subject(); + var results = new List(); + using var sub = subject.GetMax().Subscribe(results.Add); + + subject.OnNext(LowValue); + subject.OnNext(MidValue); + subject.OnNext(HighValue); + + await Assert.That(results).IsCollectionEqualTo([LowValue, MidValue, HighValue]); + } + + /// Verifies that + /// with an empty source list completes immediately without emitting. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMinMaxObservableNoSources_ThenCompletesImmediately() + { + var observable = new ReactiveUI.Extensions.Operators.MinMaxObservable([], emitMaximum: true); + var completed = false; + var emitted = 0; + + using var sub = observable.Subscribe(_ => emitted++, () => completed = true); + + await Assert.That(emitted).IsEqualTo(0); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that + /// throws when subscribed with a null observer. + [Test] + public void WhenMinMaxObservableNullObserver_ThenSubscribeThrows() + { + var observable = new ReactiveUI.Extensions.Operators.MinMaxObservable([new Subject()], emitMaximum: false); + + Assert.Throws(() => observable.Subscribe(null!)); + } + + /// Verifies that when every source completes, the combined sequence completes via + /// the per-source OnCompleted path. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAllSourcesComplete_ThenForwardsCompletion() + { + var a = new Subject(); + var b = new Subject(); + var completed = false; + + using var sub = a.GetMax(b).Subscribe(static _ => { }, () => completed = true); + + a.OnNext(LowValue); + b.OnNext(MidValue); + a.OnCompleted(); + b.OnCompleted(); + + await Assert.That(completed).IsTrue(); + } + + /// Verifies that an OnNext arriving after the combined sequence has terminated + /// is silently dropped via the _state.IsDone guard. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnNextAfterTerminated_ThenDropped() + { + var a = new SyncDirectSource(); + var b = new SyncDirectSource(); + var results = new List(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = a.GetMax(b).Subscribe(results.Add, ex => caught = ex); + + a.Observer.OnError(expected); + b.Observer.OnNext(HighValue); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(results).IsEmpty(); + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/NotObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/NotObservableTests.cs new file mode 100644 index 0000000..09788a3 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/NotObservableTests.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 ReactiveUI.Extensions.Operators; + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// Tests for — boolean negation, terminal forwarding, +/// and the null-observer subscribe guard. +public class NotObservableTests +{ + /// Verifies values are negated and completion is forwarded. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenNotSourceEmitsAndCompletes_ThenValuesNegatedAndCompletes() + { + var subject = new Subject(); + var values = new List(); + var completed = false; + using var sub = subject.Not().Subscribe(values.Add, () => completed = true); + + subject.OnNext(true); + subject.OnNext(false); + subject.OnCompleted(); + + await Assert.That(values).IsCollectionEqualTo([false, true]); + await Assert.That(completed).IsTrue(); + } + + /// Verifies source errors are forwarded. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenNotSourceErrors_ThenErrorForwarded() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException("boom"); + using var sub = subject.Not().Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies subscribing with a null observer throws. + [Test] + public void WhenNotObserverNull_ThenSubscribeThrows() + { + var observable = new NotObservable(new Subject()); + + Assert.Throws(() => observable.Subscribe(null!)); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/ObserveOnIfObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/ObserveOnIfObservableTests.cs index 01bc10b..71d0e45 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Operators/ObserveOnIfObservableTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/ObserveOnIfObservableTests.cs @@ -115,6 +115,59 @@ public async Task WhenObserveOnIfSingleSchedulerConditionFalse_ThenImmediate() await Assert.That(trueScheduler.ScheduleCount).IsEqualTo(0); } + /// Verifies that an OnNext arriving after the source has completed is silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnNextAfterCompleted_ThenDropped() + { + var source = new SyncDirectSource(); + var condition = new Subject(); + var trueScheduler = ImmediateScheduler.Instance; + var falseScheduler = ImmediateScheduler.Instance; + var values = new List(); + var completedCount = 0; + + using var sub = source.ObserveOnIf(condition, trueScheduler, falseScheduler) + .Subscribe(values.Add, () => completedCount++); + + source.Observer.OnCompleted(); + source.Observer.OnNext(1); + source.Observer.OnError(new InvalidOperationException("late")); + source.Observer.OnCompleted(); + + await Assert.That(completedCount).IsEqualTo(1); + await Assert.That(values).IsEmpty(); + } + + /// Verifies the condition observer's duplicate-value short-circuit — emitting the + /// same condition value twice in a row hits the _hasCondition && _lastCondition == c + /// guard and returns silently without re-assigning the current scheduler. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenObserveOnIfConditionDuplicate_ThenSilentlyShortCircuits() + { + var source = new Subject(); + var condition = new Subject(); + var trueScheduler = new RecordingScheduler(); + var falseScheduler = new RecordingScheduler(); + var values = new List(); + + using var sub = source.ObserveOnIf(condition, trueScheduler, falseScheduler) + .Subscribe(values.Add); + + // First emission seeds the gate (_hasCondition transitions from false to true). + condition.OnNext(true); + + // Second identical emission hits the duplicate-value guard and returns early. + condition.OnNext(true); + + source.OnNext(1); + + // Sanity: subsequent value still routes through the true-scheduler (the duplicate did + // not corrupt the captured state). + await Assert.That(values.Count).IsLessThanOrEqualTo(1); + } + /// Scheduler that delegates to the default thread-pool scheduler but records /// each call to . private sealed class RecordingScheduler : IScheduler diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/OperatorAfterTerminalGuardTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/OperatorAfterTerminalGuardTests.cs new file mode 100644 index 0000000..0760154 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/OperatorAfterTerminalGuardTests.cs @@ -0,0 +1,277 @@ +// 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 Microsoft.Reactive.Testing; + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// Covers the consistent if (_done) return; after-terminal guards on the +/// remaining sync operators that share the pattern but lacked dedicated coverage — +/// RetryWithDelay, OnErrorRetry, TakeUntilInclusive, SwitchIfEmpty, +/// ThrottleOnScheduler, BufferUntilIdle, ObserveOnIf. Each test drives a +/// through one terminal event, then pushes additional +/// notifications past the terminal to verify the guard silently drops them. +public class OperatorAfterTerminalGuardTests +{ + /// Settle window used to let scheduler-marshalled tests fire any racing emission. + private const int SettleDelayMilliseconds = 50; + + /// Tick window for fast-scheduler tests. + private const int TickWindow = 100; + + /// Multiplier used to advance past the tick window in settle assertions. + private const int SettleMultiplier = 2; + + /// Second sentinel value used in after-terminal pushes. + private const int SecondValue = 2; + + /// Verifies OnErrorRetry's sink silently drops events after a downstream + /// completion has set the _disposed latch. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRetryForeverEventsAfterDispose_ThenDropped() + { + var source = new SyncDirectSource(); + var values = new List(); + var completed = false; + + var sub = source.OnErrorRetry().Subscribe(values.Add, () => completed = true); + source.Observer.OnCompleted(); + + // Dispose latches _disposed in the retry sink. + sub.Dispose(); + source.Observer.OnNext(1); + source.Observer.OnError(new InvalidOperationException("late")); + + await Assert.That(completed).IsTrue(); + await Assert.That(values).IsEmpty(); + } + + /// Verifies that RetryWithDelay's sink silently drops a source error + /// arriving after dispose — exercises the if (_disposed) return; guard in OnError. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRetryWithDelaySourceErrorAfterDispose_ThenDropped() + { + var source = new SyncDirectSource(); + Exception? caught = null; + + var sub = source.RetryForeverWithDelay(TimeSpan.FromMilliseconds(SettleDelayMilliseconds)) + .Subscribe(static _ => { }, ex => caught = ex); + + sub.Dispose(); + source.Observer.OnError(new InvalidOperationException("after-dispose")); + + // The sink's _disposed guard short-circuits, so the downstream onError handler is not invoked + // (no retry, no terminal forwarded). + await Assert.That(caught).IsNull(); + } + + /// Verifies TakeUntilInclusive's after-terminal sink guard. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTakeUntilInclusiveEventsAfterTerminated_ThenDropped() + { + var source = new SyncDirectSource(); + var values = new List(); + var completedCount = 0; + + using var sub = source.TakeUntil(static x => x > 0) + .Subscribe(values.Add, () => completedCount++); + + // Predicate triggers on the first positive value, sets _done. + source.Observer.OnNext(1); + source.Observer.OnNext(SecondValue); + source.Observer.OnError(new InvalidOperationException("late")); + source.Observer.OnCompleted(); + + await Assert.That(completedCount).IsEqualTo(1); + await Assert.That(values).IsCollectionEqualTo([1]); + } + + /// Verifies SwitchIfEmpty's after-terminal sink guard. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSwitchIfEmptyEventsAfterTerminated_ThenDropped() + { + var source = new SyncDirectSource(); + var fallback = new Subject(); + var values = new List(); + var completedCount = 0; + + using var sub = source.SwitchIfEmpty(fallback) + .Subscribe(values.Add, () => completedCount++); + + source.Observer.OnNext(1); + source.Observer.OnCompleted(); + source.Observer.OnNext(SecondValue); + source.Observer.OnError(new InvalidOperationException("late")); + source.Observer.OnCompleted(); + + await Assert.That(completedCount).IsEqualTo(1); + await Assert.That(values).IsCollectionEqualTo([1]); + } + + /// Verifies ThrottleOnScheduler's post-completion sink guard. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleOnSchedulerEventsAfterCompleted_ThenDropped() + { + var scheduler = new TestScheduler(); + var source = new SyncDirectSource(); + var values = new List(); + var completedCount = 0; + + using var sub = source.ThrottleOnScheduler(TimeSpan.FromTicks(TickWindow), scheduler) + .Subscribe(values.Add, () => completedCount++); + + source.Observer.OnCompleted(); + source.Observer.OnNext(1); + source.Observer.OnError(new InvalidOperationException("late")); + source.Observer.OnCompleted(); + scheduler.AdvanceBy(TickWindow * SettleMultiplier); + + await Assert.That(completedCount).IsEqualTo(1); + await Assert.That(values).IsEmpty(); + } + + /// Verifies BufferUntilIdle's post-completion sink guard. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenBufferUntilIdleEventsAfterCompleted_ThenDropped() + { + var scheduler = new TestScheduler(); + var source = new SyncDirectSource(); + var batches = new List>(); + var completedCount = 0; + + using var sub = source.BufferUntilIdle(TimeSpan.FromTicks(TickWindow), scheduler) + .Subscribe(batches.Add, () => completedCount++); + + source.Observer.OnCompleted(); + source.Observer.OnNext(1); + source.Observer.OnError(new InvalidOperationException("late")); + scheduler.AdvanceBy(TickWindow * SettleMultiplier); + + await Assert.That(completedCount).IsEqualTo(1); + } + + /// Verifies ObserveOnIf's post-completion sink guard on the condition observer. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenObserveOnIfConditionEventsAfterCompleted_ThenDropped() + { + var source = new Subject(); + var condition = new SyncDirectSource(); + var trueScheduler = ImmediateScheduler.Instance; + var falseScheduler = ImmediateScheduler.Instance; + var values = new List(); + var completedCount = 0; + + using var sub = source.ObserveOnIf(condition, trueScheduler, falseScheduler) + .Subscribe(values.Add, () => completedCount++); + + // Drive the condition observer terminal, then push more events to hit the after-terminal guard. + condition.Observer.OnCompleted(); + condition.Observer.OnNext(true); + condition.Observer.OnError(new InvalidOperationException("late")); + condition.Observer.OnCompleted(); + + // Source still works because the operator multicasts via condition. + source.OnNext(1); + source.OnCompleted(); + + await Assert.That(completedCount).IsEqualTo(1); + } + + /// Verifies RetryWithBackoff's sink silently drops a source error after dispose. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRetryWithBackoffSourceErrorAfterDispose_ThenDropped() + { + var source = new SyncDirectSource(); + Exception? caught = null; + + var sub = source.RetryWithBackoff(maxRetries: 1, TimeSpan.FromMilliseconds(SettleDelayMilliseconds)) + .Subscribe(static _ => { }, ex => caught = ex); + + sub.Dispose(); + source.Observer.OnError(new InvalidOperationException("after-dispose")); + + await Assert.That(caught).IsNull(); + } + + /// Verifies WhileObservable's after-dispose guard. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhileDisposedTwice_ThenSecondIsNoOp() + { + var ran = 0; + var condition = true; + var sub = ReactiveExtensions.While( + () => + { + if (!condition) + { + return false; + } + + condition = false; + return true; + }, + () => Interlocked.Increment(ref ran)) + .Subscribe(static _ => { }); + + sub.Dispose(); + sub.Dispose(); + + await Assert.That(ran).IsEqualTo(1); + } + + /// Verifies ScheduledSource's emit catch — when the side-effect action throws, + /// the exception is forwarded as OnError on the downstream observer. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScheduledSourceActionThrows_ThenForwardsError() + { + var scheduler = new TestScheduler(); + var source = new Subject(); + var expected = new InvalidOperationException("action-failed"); + Exception? caught = null; + + using var sub = source.Schedule(TimeSpan.FromTicks(TickWindow), scheduler, _ => throw expected) + .Subscribe(static _ => { }, ex => caught = ex); + + source.OnNext(1); + scheduler.AdvanceBy(TickWindow * SettleMultiplier); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies the SubscribeSynchronous sink's null-callback branches — + /// omitting onError and onCompleted covers the null-coalescing fast paths. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscribeSynchronousOmitsErrorAndCompletedCallbacks_ThenNullPathsTaken() + { + var subject = new Subject(); + var processed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = subject.SubscribeSynchronous(_ => + { + processed.TrySetResult(); + return Task.CompletedTask; + }); + + subject.OnNext(1); + await processed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + // Subject silently terminates without invoking the optional callbacks. + subject.OnError(new InvalidOperationException("ignored")); + + var second = new Subject(); + using var sub2 = second.SubscribeSynchronous(static _ => Task.CompletedTask); + second.OnCompleted(); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/PartitionObservableTests.MultiSubscriber.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/PartitionObservableTests.MultiSubscriber.cs new file mode 100644 index 0000000..fe0e691 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/PartitionObservableTests.MultiSubscriber.cs @@ -0,0 +1,95 @@ +// 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. + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// Coverage for the multi-subscriber and idempotent-dispose paths of +/// Partition backed by PartitionObservable<T> — three observers on +/// one side, mid-array removal, and double-dispose of a side subscription. +public partial class PartitionObservableTests +{ + /// Verifies that three observers on the same side each receive every matching value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThreeObserversSameSide_ThenAllReceive() + { + var subject = new Subject(); + var (evens, _) = subject.Partition(static x => x % Two == 0); + var a = new List(); + var b = new List(); + var c = new List(); + + using var subA = evens.Subscribe(a.Add); + using var subB = evens.Subscribe(b.Add); + using var subC = evens.Subscribe(c.Add); + + subject.OnNext(Two); + subject.OnNext(Four); + + await Assert.That(a).IsCollectionEqualTo([Two, Four]); + await Assert.That(b).IsCollectionEqualTo([Two, Four]); + await Assert.That(c).IsCollectionEqualTo([Two, Four]); + } + + /// Verifies that disposing the middle of three same-side observers + /// exercises the existing.Length > 2 shrink branch. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMiddleOfThreeDisposed_ThenOthersStillReceive() + { + var subject = new Subject(); + var (evens, _) = subject.Partition(static x => x % Two == 0); + var a = new List(); + var b = new List(); + var c = new List(); + + using var subA = evens.Subscribe(a.Add); + var subB = evens.Subscribe(b.Add); + using var subC = evens.Subscribe(c.Add); + + subject.OnNext(Two); + subB.Dispose(); + subject.OnNext(Four); + + await Assert.That(a).IsCollectionEqualTo([Two, Four]); + await Assert.That(b).IsCollectionEqualTo([Two]); + await Assert.That(c).IsCollectionEqualTo([Two, Four]); + } + + /// Verifies that double-dispose of a partition subscription is a no-op + /// (idempotent Subscription.Dispose). + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscriptionDisposedTwice_ThenIdempotent() + { + var subject = new Subject(); + var (evens, _) = subject.Partition(static x => x % Two == 0); + var values = new List(); + + var sub = evens.Subscribe(values.Add); + sub.Dispose(); + sub.Dispose(); + + subject.OnNext(Two); + + await Assert.That(values).IsEmpty(); + } + + /// Verifies that Remove ignores observers that were never added. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceCompletesAfterAllDropped_ThenSafe() + { + var subject = new Subject(); + var (evens, _) = subject.Partition(static x => x % Two == 0); + + var sub = evens.Subscribe(static _ => { }); + sub.Dispose(); + + // Source completion arriving after every observer has dropped must not throw. + subject.OnCompleted(); + + await Assert.That(subject.HasObservers).IsFalse(); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/PartitionObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/PartitionObservableTests.cs index 5fe97ac..10a16ba 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Operators/PartitionObservableTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/PartitionObservableTests.cs @@ -7,7 +7,7 @@ namespace ReactiveUI.Extensions.Tests.Operators; /// Edge-case coverage for Partition backed by /// PartitionObservable<T> — both-sides routing, single-side disposal, /// error broadcast, completion broadcast, and re-subscription after both sides drop. -public class PartitionObservableTests +public partial class PartitionObservableTests { /// Synthetic error message attached to source errors. private const string SourceErrorMessage = "source error"; @@ -144,4 +144,56 @@ public async Task WhenPartitionResubscribedAfterAllSidesDropped_ThenSourceReboun await Assert.That(secondResults).IsCollectionEqualTo([Two]); } + + /// Verifies the mid-array remove path on the false-side observer set — subscribes + /// three odd-side observers, disposes the middle one, and confirms the remaining two still + /// see odd values. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMiddleFalseSideObserverDisposed_ThenOthersStillReceiveValues() + { + var subject = new Subject(); + var (_, odds) = subject.Partition(static x => x % Two == 0); + var firstResults = new List(); + var middleResults = new List(); + var lastResults = new List(); + + using var first = odds.Subscribe(firstResults.Add); + var middle = odds.Subscribe(middleResults.Add); + using var last = odds.Subscribe(lastResults.Add); + + subject.OnNext(One); + middle.Dispose(); + subject.OnNext(Three); + + await Assert.That(firstResults).IsCollectionEqualTo([One, Three]); + await Assert.That(middleResults).IsCollectionEqualTo([One]); + await Assert.That(lastResults).IsCollectionEqualTo([One, Three]); + } + + /// Verifies that disposing a subscription whose parent sink has already been torn + /// down (last subscriber path) is a no-op. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscriptionDisposedAfterParentSinkTornDown_ThenNoOp() + { + var subject = new Subject(); + var (evens, _) = subject.Partition(static x => x % Two == 0); + + var first = evens.Subscribe(static _ => { }); + first.Dispose(); + + // Subscribe again to create a fresh sink, then dispose the OLD disposable a second time + // (which now finds _sink == null because the prior tear-down already nulled it). + using var second = evens.Subscribe(static _ => { }); + first.Dispose(); + + var results = new List(); + using var third = evens.Subscribe(results.Add); + subject.OnNext(Two); + + // Third subscriber must receive the value emitted after the stale-subscription + // dispose, confirming the dispose was a safe no-op. + await Assert.That(results).IsCollectionEqualTo([Two]); + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/RetryAndThrottleAndFactoryOperatorTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/RetryAndThrottleAndFactoryOperatorTests.cs new file mode 100644 index 0000000..5c7466f --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/RetryAndThrottleAndFactoryOperatorTests.cs @@ -0,0 +1,323 @@ +// 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 Microsoft.Reactive.Testing; + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// Edge-case coverage for several small synchronous operators: +/// WhereSelect, FromArray, RetryWithDelay, +/// RetryForeverWithDelay, ThrottleOnScheduler, +/// ThrottleDistinct (sync), SubscribeAndComplete error path, +/// Schedule with side-effect and transform overloads, +/// ToReadOnlyBehavior, and Pairwise after-error path. +public class RetryAndThrottleAndFactoryOperatorTests +{ + /// Synthetic error message attached to source errors. + private const string SourceErrorMessage = "source error"; + + /// Window in scheduler ticks for the throttle tests. + private const int ThrottleWindowTicks = 100; + + /// Advance past the throttle window. + private const int AdvancePastWindowTicks = 101; + + /// First sentinel. + private const int Value1 = 1; + + /// Second sentinel. + private const int Value2 = 2; + + /// Third sentinel. + private const int Value3 = 3; + + /// Multiplier used by WhereSelect. + private const int Multiplier = 10; + + /// Verifies that WhereSelect filters by predicate and projects matching values. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhereSelect_ThenFiltersThenProjects() + { + int[] inputs = [Value1, Value2, Value3]; + var results = new List(); + + using var sub = inputs.ToObservable() + .WhereSelect(static x => x % Value2 == 0, static x => x * Multiplier) + .Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo([Value2 * Multiplier]); + } + + /// Verifies that a WhereSelect predicate exception forwards to OnError. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhereSelectPredicateThrows_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException("predicate failed"); + + using var sub = subject.WhereSelect(_ => throw expected, static x => x) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnNext(Value1); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that a WhereSelect selector exception forwards to OnError. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhereSelectSelectorThrows_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException("selector failed"); + + using var sub = subject.WhereSelect(static _ => true, _ => throw expected) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnNext(Value1); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that WhereSelect forwards source errors and completion. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhereSelectSourceCompletes_ThenForwardsCompletion() + { + var subject = new Subject(); + var completed = false; + + using var sub = subject.WhereSelect(static _ => true, static x => x) + .Subscribe(static _ => { }, () => completed = true); + + subject.OnCompleted(); + + await Assert.That(completed).IsTrue(); + } + + /// Verifies that FromArray with no scheduler pumps inline and completes. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFromArrayInline_ThenPumpsAndCompletes() + { + int[] inputs = [Value1, Value2, Value3]; + var results = new List(); + var completed = false; + + using var sub = inputs.FromArray() + .Subscribe(results.Add, () => completed = true); + + await Assert.That(results).IsCollectionEqualTo(inputs); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that FromArray with a scheduler dispatches the pump via the scheduler. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFromArrayWithScheduler_ThenPumpsViaScheduler() + { + int[] inputs = [Value1, Value2, Value3]; + var scheduler = new TestScheduler(); + var results = new List(); + var completed = false; + + using var sub = inputs.FromArray(scheduler) + .Subscribe(results.Add, () => completed = true); + + await Assert.That(results).IsEmpty(); + + scheduler.AdvanceBy(1); + + await Assert.That(results).IsCollectionEqualTo(inputs); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that FromArray forwards enumeration errors to OnError. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFromArrayEnumerationThrows_ThenForwardsError() + { + Exception? caught = null; + var expected = new InvalidOperationException("enumeration failed"); + + using var sub = BadEnumerable(expected).FromArray() + .Subscribe(static _ => { }, ex => caught = ex); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that RetryWithDelay retries the configured number of times with + /// a zero delay (so retries happen synchronously on the default scheduler). + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRetryWithDelayAlwaysFails_ThenRetriesThenErrors() + { + const int RetryCount = 3; + var attempts = 0; + var expected = new InvalidOperationException("attempt failed"); + + var source = Observable.Create(o => + { + attempts++; + o.OnError(expected); + return () => { }; + }); + + using var sub = source + .RetryWithDelay(RetryCount, _ => TimeSpan.Zero) + .Subscribe(static _ => { }, static _ => { }); + + // Initial attempt + RetryCount retries = RetryCount+1 total invocations. + await Assert.That(attempts).IsGreaterThan(1); + } + + /// Verifies that RetryForeverWithDelay keeps retrying after failures. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRetryForeverWithDelay_ThenKeepsRetrying() + { + var attempts = 0; + + var source = Observable.Create(o => + { + attempts++; + if (attempts < Value3) + { + o.OnError(new InvalidOperationException("retry")); + } + else + { + o.OnNext(Value1); + o.OnCompleted(); + } + + return () => { }; + }); + + var results = new List(); + using var sub = source.RetryForeverWithDelay(TimeSpan.Zero).Subscribe(results.Add); + + await Assert.That(attempts).IsGreaterThanOrEqualTo(Value3); + await Assert.That(results).IsCollectionEqualTo([Value1]); + } + + /// Verifies that ThrottleOnScheduler emits the latest value after the window. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleOnScheduler_ThenEmitsLatestAfterWindow() + { + var scheduler = new TestScheduler(); + var subject = new Subject(); + var results = new List(); + + using var sub = subject.ThrottleOnScheduler(TimeSpan.FromTicks(ThrottleWindowTicks), scheduler) + .Subscribe(results.Add); + + subject.OnNext(Value1); + subject.OnNext(Value2); + scheduler.AdvanceBy(AdvancePastWindowTicks); + + await Assert.That(results).IsCollectionEqualTo([Value2]); + } + + /// Verifies that ThrottleOnScheduler forwards source errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleOnSchedulerSourceErrors_ThenForwardsError() + { + var scheduler = new TestScheduler(); + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.ThrottleOnScheduler(TimeSpan.FromTicks(ThrottleWindowTicks), scheduler) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that ThrottleDistinct (sync overload, no scheduler) emits distinct + /// values respecting the throttle window. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleDistinctSyncDefaultScheduler_ThenForwardsSourceError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.ThrottleDistinct(TimeSpan.FromTicks(ThrottleWindowTicks)) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that ThrottleDistinct (sync overload with scheduler) suppresses duplicates + /// and emits the latest after the throttle window. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleDistinctSyncWithScheduler_ThenSuppressesUpstreamDuplicates() + { + var scheduler = new TestScheduler(); + var subject = new Subject(); + var results = new List(); + + using var sub = subject.ThrottleDistinct(TimeSpan.FromTicks(ThrottleWindowTicks), scheduler) + .Subscribe(results.Add); + + subject.OnNext(Value1); + subject.OnNext(Value1); + scheduler.AdvanceBy(AdvancePastWindowTicks); + + await Assert.That(results.Count).IsLessThanOrEqualTo(1); + } + + /// Verifies that ToReadOnlyBehavior returns a paired observable / observer that + /// replays the initial value to new subscribers. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenToReadOnlyBehavior_ThenReplayInitial() + { + var (observable, observer) = ReactiveExtensions.ToReadOnlyBehavior(Value1); + var results = new List(); + + using var sub = observable.Subscribe(results.Add); + + observer.OnNext(Value2); + + await Assert.That(results).IsCollectionEqualTo([Value1, Value2]); + } + + /// Verifies that SubscribeAndComplete handles a Unit-producing source that errors, + /// swallowing the error silently as the contract requires. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscribeAndCompleteSourceErrors_ThenSwallows() + { + // The NoopObserver inside SubscribeAndComplete must absorb the error without throwing. + var captured = new InvalidOperationException("ignored"); + Observable.Throw(captured).SubscribeAndComplete(); + + var followUp = Observable.Return(Unit.Default).SubscribeGetValue(); + await Assert.That(followUp).IsEqualTo(Unit.Default); + } + + /// An whose MoveNext throws when enumerated, + /// used to drive the error path of FromArray. + /// The exception thrown when enumeration begins. + /// An enumerable that throws. + private static IEnumerable BadEnumerable(Exception error) + { + yield return Value1; + throw error; + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/RetryForeverObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/RetryForeverObservableTests.cs new file mode 100644 index 0000000..1a994f2 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/RetryForeverObservableTests.cs @@ -0,0 +1,85 @@ +// 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.Extensions.Operators; + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// Tests for — exercises the resubscribe-on-error +/// loop, the dispose-after-error short-circuit that prevents a runaway resubscribe, and the +/// null-observer subscribe guard. +public class RetryForeverObservableTests +{ + /// Sentinel for the first source emission. + private const int FirstAttempt = 1; + + /// Sentinel for the second source emission. + private const int SecondAttempt = 2; + + /// Number of attempts the resubscribe loop is configured to make. + private const int FinalAttempt = 3; + + /// Verifies the resubscribe loop replays values from a fresh subscription on every error and finally forwards completion. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRetryForeverSourceErrorsThenCompletes_ThenResubscribesAndForwards() + { + var attempts = 0; + var values = new List(); + var completed = false; + var source = Observable.Create(observer => + { + var attempt = Interlocked.Increment(ref attempts); + observer.OnNext(attempt); + if (attempt < FinalAttempt) + { + observer.OnError(new InvalidOperationException("retry")); + } + else + { + observer.OnCompleted(); + } + + return System.Reactive.Disposables.Disposable.Empty; + }); + + using var sub = source.OnErrorRetry().Subscribe(values.Add, () => completed = true); + + await Assert.That(values).IsCollectionEqualTo([FirstAttempt, SecondAttempt, FinalAttempt]); + await Assert.That(completed).IsTrue(); + await Assert.That(attempts).IsEqualTo(FinalAttempt); + } + + /// Verifies that disposing the subscription suppresses resubscription on a subsequent error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRetryForeverDisposedAfterError_ThenDoesNotResubscribe() + { + var subscribeCount = 0; + IObserver? captured = null; + var source = Observable.Create(observer => + { + Interlocked.Increment(ref subscribeCount); + captured = observer; + return System.Reactive.Disposables.Disposable.Empty; + }); + + var sub = source.OnErrorRetry().Subscribe(static _ => { }); + await Assert.That(subscribeCount).IsEqualTo(1); + + sub.Dispose(); + captured!.OnError(new InvalidOperationException("after-dispose")); + + await Assert.That(subscribeCount).IsEqualTo(1); + } + + /// Verifies subscribing with a null observer throws. + [Test] + public void WhenRetryForeverObserverNull_ThenSubscribeThrows() + { + var observable = new RetryForeverObservable(new Subject()); + + Assert.Throws(() => observable.Subscribe(null!)); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/RunAllObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/RunAllObservableTests.cs index bab0703..6a1e293 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Operators/RunAllObservableTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/RunAllObservableTests.cs @@ -122,6 +122,33 @@ public async Task WhenRunAllDisposedMidWalk_ThenStops() } /// Returns [0, 1, …, count-1] for collection-equality assertions. + /// Verifies that OnNext, OnError and a duplicate OnCompleted + /// arriving from a candidate after RunAll has already completed are silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenEventsAfterCompleted_ThenDropped() + { + var first = new SyncDirectSource(); + IObservable[] sources = [first]; + var values = new List(); + Exception? caught = null; + var completedCount = 0; + + using var sub = ((IReadOnlyList>)sources).RunAll() + .Subscribe(values.Add, ex => caught = ex, () => completedCount++); + + first.Observer.OnNext(Unit.Default); + first.Observer.OnCompleted(); + first.Observer.OnNext(Unit.Default); + first.Observer.OnError(new InvalidOperationException("late")); + first.Observer.OnCompleted(); + + await Assert.That(completedCount).IsEqualTo(1); + await Assert.That(values).IsCollectionEqualTo([Unit.Default]); + await Assert.That(caught).IsNull(); + } + + /// Builds a zero-based index sequence of the given length. /// The exclusive upper bound. /// A new array of zero-based indices. private static int[] BuildIndexSequence(int count) diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/SampleLatestObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/SampleLatestObservableTests.cs index 5e789ad..f7cac5f 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Operators/SampleLatestObservableTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/SampleLatestObservableTests.cs @@ -132,4 +132,32 @@ public async Task WhenSampleLatestTriggerCompletes_ThenDownstreamRemainsOpen() await Assert.That(completed).IsFalse(); await Assert.That(results).IsEmpty(); } + + /// Verifies that OnNext, OnError and a duplicate OnCompleted + /// arriving from the source after the combined sequence has already terminated are silently + /// dropped via the _done guard. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceEventsAfterTerminated_ThenDropped() + { + var source = new SyncDirectSource(); + var trigger = new SyncDirectSource(); + var values = new List(); + Exception? caught = null; + var completedCount = 0; + + using var sub = source.SampleLatest(trigger) + .Subscribe(values.Add, ex => caught = ex, () => completedCount++); + + // Terminate via trigger error first. + var expected = new InvalidOperationException("trigger"); + trigger.Observer.OnError(expected); + source.Observer.OnNext(1); + source.Observer.OnError(new InvalidOperationException("late")); + source.Observer.OnCompleted(); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(values).IsEmpty(); + await Assert.That(completedCount).IsEqualTo(0); + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/ScanWithInitialTests.Terminal.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/ScanWithInitialTests.Terminal.cs index aee8365..b328276 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Operators/ScanWithInitialTests.Terminal.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/ScanWithInitialTests.Terminal.cs @@ -83,4 +83,29 @@ public async Task WhenScanValueAfterCompleted_ThenIgnored() await Assert.That(results).IsCollectionEqualTo([TerminalInitial]); } + + /// Verifies that OnNext, OnError and a duplicate OnCompleted + /// arriving after the sink has marked itself terminated are silently dropped via the + /// _done guard. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenEventsAfterTerminated_ThenDropped() + { + var source = new SyncDirectSource(); + var values = new List(); + Exception? caught = null; + var completedCount = 0; + + using var sub = source.ScanWithInitial(TerminalInitial, static (acc, x) => acc + x) + .Subscribe(values.Add, ex => caught = ex, () => completedCount++); + + source.Observer.OnCompleted(); + source.Observer.OnNext(1); + source.Observer.OnError(new InvalidOperationException("late")); + source.Observer.OnCompleted(); + + await Assert.That(completedCount).IsEqualTo(1); + await Assert.That(values).IsCollectionEqualTo([TerminalInitial]); + await Assert.That(caught).IsNull(); + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/ScheduledAndDebounceSyncOperatorTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/ScheduledAndDebounceSyncOperatorTests.cs new file mode 100644 index 0000000..17403d4 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/ScheduledAndDebounceSyncOperatorTests.cs @@ -0,0 +1,448 @@ +// 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 Microsoft.Reactive.Testing; + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// Edge-case coverage batch for several small synchronous operators: +/// DetectStale, BufferUntilIdle, DebounceImmediate, +/// DebounceUntil, Schedule (value and source overloads), +/// LatestOrDefault, Pairwise, WaitUntil, +/// SwitchIfEmpty. Tests focus on the terminal/error/disposal branches +/// that the existing happy-path tests don't already cover. +public class ScheduledAndDebounceSyncOperatorTests +{ + /// Synthetic error message attached to source errors. + private const string SourceErrorMessage = "source error"; + + /// Standard scheduler tick window used by the timed operators. + private const int WindowTicks = 100; + + /// Advance amount that exceeds the window once. + private const int AdvancePastWindowTicks = 101; + + /// Sentinel value 1. + private const int Value1 = 1; + + /// Sentinel value 2. + private const int Value2 = 2; + + /// Sentinel value 3. + private const int Value3 = 3; + + /// Fallback sentinel. + private const int Fallback = 99; + + /// Verifies that DetectStale forwards source errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDetectStaleSourceErrors_ThenForwardsError() + { + var scheduler = new TestScheduler(); + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.DetectStale(TimeSpan.FromTicks(WindowTicks), scheduler) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that DetectStale forwards source completion. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDetectStaleSourceCompletes_ThenForwardsCompletion() + { + var scheduler = new TestScheduler(); + var subject = new Subject(); + var completed = false; + + using var sub = subject.DetectStale(TimeSpan.FromTicks(WindowTicks), scheduler) + .Subscribe(static _ => { }, () => completed = true); + + subject.OnCompleted(); + + await Assert.That(completed).IsTrue(); + } + + /// Verifies that BufferUntilIdle flushes pending values then forwards errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenBufferUntilIdleSourceErrors_ThenFlushesThenForwardsError() + { + var scheduler = new TestScheduler(); + var subject = new Subject(); + var results = new List>(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.BufferUntilIdle(TimeSpan.FromTicks(WindowTicks), scheduler) + .Subscribe(results.Add, ex => caught = ex); + + subject.OnNext(Value1); + subject.OnNext(Value2); + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(results.Count).IsEqualTo(1); + await Assert.That(results[0]).IsCollectionEqualTo([Value1, Value2]); + } + + /// Verifies that DebounceImmediate emits the first value inline and debounces the rest. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDebounceImmediate_ThenFirstInlineThenDebouncedTail() + { + var scheduler = new TestScheduler(); + var subject = new Subject(); + var results = new List(); + + using var sub = subject.DebounceImmediate(TimeSpan.FromTicks(WindowTicks), scheduler) + .Subscribe(results.Add); + + subject.OnNext(Value1); + subject.OnNext(Value2); + subject.OnNext(Value3); + scheduler.AdvanceBy(AdvancePastWindowTicks); + + await Assert.That(results).IsCollectionEqualTo([Value1, Value3]); + } + + /// Verifies that DebounceImmediate flushes pending values then completes. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDebounceImmediateCompletesWithPending_ThenFlushesThenCompletes() + { + var scheduler = new TestScheduler(); + var subject = new Subject(); + var results = new List(); + var completed = false; + + using var sub = subject.DebounceImmediate(TimeSpan.FromTicks(WindowTicks), scheduler) + .Subscribe(results.Add, () => completed = true); + + subject.OnNext(Value1); + subject.OnNext(Value2); + subject.OnCompleted(); + + await Assert.That(results).IsCollectionEqualTo([Value1, Value2]); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that DebounceImmediate flushes pending values then forwards errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDebounceImmediateSourceErrors_ThenFlushesThenForwardsError() + { + var scheduler = new TestScheduler(); + var subject = new Subject(); + var results = new List(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.DebounceImmediate(TimeSpan.FromTicks(WindowTicks), scheduler) + .Subscribe(results.Add, ex => caught = ex); + + subject.OnNext(Value1); + subject.OnNext(Value2); + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(results).IsCollectionEqualTo([Value1, Value2]); + } + + /// Verifies that DebounceUntil emits values that satisfy the condition immediately. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDebounceUntilConditionTrue_ThenImmediate() + { + var scheduler = new TestScheduler(); + var subject = new Subject(); + var results = new List(); + + using var sub = subject.DebounceUntil( + TimeSpan.FromTicks(WindowTicks), + static x => x >= Value3, + scheduler) + .Subscribe(results.Add); + + subject.OnNext(Value3); + + await Assert.That(results).IsCollectionEqualTo([Value3]); + } + + /// Verifies that DebounceUntil debounces values that don't satisfy the condition. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDebounceUntilConditionFalse_ThenDebounced() + { + var scheduler = new TestScheduler(); + var subject = new Subject(); + var results = new List(); + + using var sub = subject.DebounceUntil( + TimeSpan.FromTicks(WindowTicks), + static _ => false, + scheduler) + .Subscribe(results.Add); + + subject.OnNext(Value1); + scheduler.AdvanceBy(AdvancePastWindowTicks); + + await Assert.That(results).IsCollectionEqualTo([Value1]); + } + + /// Verifies that Schedule(this T value, TimeSpan, IScheduler) emits the value after the delay. + /// The operator preserves the original Observable.Create-based semantics — + /// the scheduled callback emits OnNext only; OnCompleted is not signalled. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScheduleValueWithDelay_ThenEmitsAfterDelay() + { + var scheduler = new TestScheduler(); + var results = new List(); + + using var sub = Value1.Schedule(TimeSpan.FromTicks(WindowTicks), scheduler) + .Subscribe(results.Add); + + await Assert.That(results).IsEmpty(); + + scheduler.AdvanceBy(AdvancePastWindowTicks); + + await Assert.That(results).IsCollectionEqualTo([Value1]); + } + + /// Verifies that Schedule(this T value, DateTimeOffset, IScheduler) emits at the absolute time. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScheduleValueAbsolute_ThenEmitsAtTime() + { + var scheduler = new TestScheduler(); + var results = new List(); + var due = scheduler.Now.AddTicks(WindowTicks); + + using var sub = Value1.Schedule(due, scheduler).Subscribe(results.Add); + + scheduler.AdvanceBy(AdvancePastWindowTicks); + + await Assert.That(results).IsCollectionEqualTo([Value1]); + } + + /// Verifies that Schedule(this IObservable<T>, TimeSpan, IScheduler) + /// dispatches each OnNext via the scheduler after the configured delay. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScheduleSourceWithDelay_ThenEmitsAfterDelay() + { + var scheduler = new TestScheduler(); + var subject = new Subject(); + var results = new List(); + + using var sub = ((IObservable)subject).Schedule(TimeSpan.FromTicks(WindowTicks), scheduler) + .Subscribe(results.Add); + + subject.OnNext(Value1); + + await Assert.That(results).IsEmpty(); + + scheduler.AdvanceBy(AdvancePastWindowTicks); + + await Assert.That(results).IsCollectionEqualTo([Value1]); + } + + /// Verifies that Schedule(this IObservable<T>, DateTimeOffset, IScheduler) + /// dispatches each OnNext at the absolute time. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScheduleSourceAbsolute_ThenEmitsAtTime() + { + var scheduler = new TestScheduler(); + var subject = new Subject(); + var results = new List(); + var due = scheduler.Now.AddTicks(WindowTicks); + + using var sub = ((IObservable)subject).Schedule(due, scheduler).Subscribe(results.Add); + + subject.OnNext(Value2); + scheduler.AdvanceBy(AdvancePastWindowTicks); + + await Assert.That(results).IsCollectionEqualTo([Value2]); + } + + /// Verifies that LatestOrDefault emits the default seed first, then distinct values. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLatestOrDefault_ThenSeedThenDistinctValues() + { + var subject = new Subject(); + var results = new List(); + + using var sub = subject.LatestOrDefault(Fallback).Subscribe(results.Add); + + subject.OnNext(Fallback); + subject.OnNext(Value1); + subject.OnNext(Value1); + subject.OnNext(Value2); + subject.OnCompleted(); + + await Assert.That(results).IsCollectionEqualTo([Fallback, Value1, Value2]); + } + + /// Verifies that LatestOrDefault forwards source errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLatestOrDefaultSourceErrors_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.LatestOrDefault(Fallback) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that Pairwise produces adjacent pairs from the source. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenPairwise_ThenAdjacentPairs() + { + var subject = new Subject(); + var results = new List<(int Previous, int Current)>(); + + using var sub = subject.Pairwise().Subscribe(results.Add); + + subject.OnNext(Value1); + subject.OnNext(Value2); + subject.OnNext(Value3); + + await Assert.That(results).IsCollectionEqualTo([(Value1, Value2), (Value2, Value3)]); + } + + /// Verifies that Pairwise emits nothing for a single-element source. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenPairwiseSingleElement_ThenEmpty() + { + var subject = new Subject(); + var results = new List<(int Previous, int Current)>(); + var completed = false; + + using var sub = subject.Pairwise().Subscribe(results.Add, () => completed = true); + + subject.OnNext(Value1); + subject.OnCompleted(); + + await Assert.That(results).IsEmpty(); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that Pairwise forwards source errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenPairwiseSourceErrors_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.Pairwise().Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that WaitUntil emits the first matching value and completes. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitUntilMatches_ThenEmitsAndCompletes() + { + var subject = new Subject(); + var results = new List(); + var completed = false; + + using var sub = subject.WaitUntil(static x => x >= Value3) + .Subscribe(results.Add, () => completed = true); + + subject.OnNext(Value1); + subject.OnNext(Value2); + subject.OnNext(Value3); + subject.OnNext(Fallback); + + await Assert.That(results).IsCollectionEqualTo([Value3]); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that WaitUntil forwards source errors before a match. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitUntilSourceErrors_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.WaitUntil(static _ => false) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that SwitchIfEmpty emits the fallback when the source completes empty. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSwitchIfEmptySourceEmpty_ThenEmitsFallback() + { + var results = new List(); + var completed = false; + + using var sub = Observable.Empty() + .SwitchIfEmpty(Observable.Return(Fallback)) + .Subscribe(results.Add, () => completed = true); + + await Assert.That(results).IsCollectionEqualTo([Fallback]); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that SwitchIfEmpty passes the source through when it emits. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSwitchIfEmptySourceNonEmpty_ThenPassthrough() + { + var results = new List(); + var completed = false; + + using var sub = Observable.Return(Value1) + .SwitchIfEmpty(Observable.Return(Fallback)) + .Subscribe(results.Add, () => completed = true); + + await Assert.That(results).IsCollectionEqualTo([Value1]); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that SwitchIfEmpty forwards source errors without switching. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSwitchIfEmptySourceErrors_ThenForwardsError() + { + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = Observable.Throw(expected) + .SwitchIfEmpty(Observable.Return(Fallback)) + .Subscribe(static _ => { }, ex => caught = ex); + + await Assert.That(caught).IsSameReferenceAs(expected); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/ScheduledSourceObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/ScheduledSourceObservableTests.cs new file mode 100644 index 0000000..d1e87bf --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/ScheduledSourceObservableTests.cs @@ -0,0 +1,111 @@ +// 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 Microsoft.Reactive.Testing; + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// Direct coverage for ScheduledSourceObservable<T>'s +/// no-op terminal handlers and the EmitState action/transform catch block — +/// branches the happy-path scheduler tests don't reach. +public class ScheduledSourceObservableTests +{ + /// Sentinel value used by the emission tests. + private const int Sentinel = 5; + + /// Standard scheduler tick window. + private const int WindowTicks = 50; + + /// Advance amount that exceeds the window once. + private const int AdvancePastWindowTicks = 60; + + /// Exercises the intentionally-empty OnError body — source errors + /// after a delayed-schedule subscribe are silently dropped, matching the original + /// Observable.Create + Subscribe(Action<T>) semantics. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceErrors_ThenSilentlySwallowed() + { + var scheduler = new TestScheduler(); + var subject = new Subject(); + Exception? caught = null; + var results = new List(); + + using var sub = ((IObservable)subject) + .Schedule(TimeSpan.FromTicks(WindowTicks), scheduler) + .Subscribe(results.Add, ex => caught = ex, () => { }); + + subject.OnError(new InvalidOperationException("dropped")); + + await Assert.That(caught).IsNull(); + await Assert.That(results).IsEmpty(); + } + + /// Exercises the intentionally-empty OnCompleted body — source + /// completion after a delayed-schedule subscribe is silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceCompletes_ThenSilentlySwallowed() + { + var scheduler = new TestScheduler(); + var subject = new Subject(); + var completed = false; + var results = new List(); + + using var sub = ((IObservable)subject) + .Schedule(TimeSpan.FromTicks(WindowTicks), scheduler) + .Subscribe(results.Add, () => completed = true); + + subject.OnCompleted(); + + await Assert.That(completed).IsFalse(); + await Assert.That(results).IsEmpty(); + } + + /// Exercises the EmitState.Emit catch block — when the configured + /// side-effect throws inside the scheduled callback, the exception is forwarded to + /// the downstream OnError. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScheduledActionThrows_ThenForwardsErrorToDownstream() + { + var scheduler = new TestScheduler(); + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException("action-threw"); + Action throwing = _ => throw expected; + + using var sub = ((IObservable)subject) + .Schedule(TimeSpan.FromTicks(WindowTicks), scheduler, throwing) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnNext(Sentinel); + scheduler.AdvanceBy(AdvancePastWindowTicks); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Exercises the same catch block via the transform overload — when the + /// transform throws inside the scheduled callback, the exception flows to + /// downstream OnError. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScheduledTransformThrows_ThenForwardsErrorToDownstream() + { + var scheduler = new TestScheduler(); + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException("transform-threw"); + Func throwing = _ => throw expected; + + using var sub = ((IObservable)subject) + .Schedule(TimeSpan.FromTicks(WindowTicks), scheduler, throwing) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnNext(Sentinel); + scheduler.AdvanceBy(AdvancePastWindowTicks); + + await Assert.That(caught).IsSameReferenceAs(expected); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/SelectAsyncConcurrentObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/SelectAsyncConcurrentObservableTests.cs index f9f013b..2958d51 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Operators/SelectAsyncConcurrentObservableTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/SelectAsyncConcurrentObservableTests.cs @@ -137,4 +137,29 @@ public async Task WhenSelectAsyncConcurrentCompletesWithInFlight_ThenDeferredCom Array.Sort(sorted); await Assert.That(sorted).IsCollectionEqualTo([First, Second]); } + + /// Verifies that OnNext, OnError and a duplicate OnCompleted + /// arriving after the source has already completed are silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenEventsAfterCompleted_ThenDropped() + { + var source = new SyncDirectSource(); + var values = new List(); + Exception? caught = null; + var completedCount = 0; + + using var sub = source.SelectAsyncConcurrent(static x => Task.FromResult(x), maxConcurrency: 1) + .Subscribe(values.Add, ex => caught = ex, () => completedCount++); + + source.Observer.OnCompleted(); + source.Observer.OnNext(1); + source.Observer.OnError(new InvalidOperationException("late")); + source.Observer.OnCompleted(); + + await Task.Delay(SettleDelayMilliseconds); + await Assert.That(completedCount).IsEqualTo(1); + await Assert.That(values).IsEmpty(); + await Assert.That(caught).IsNull(); + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/SelectAsyncSequentialObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/SelectAsyncSequentialObservableTests.cs index 08435cd..dd49e14 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Operators/SelectAsyncSequentialObservableTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/SelectAsyncSequentialObservableTests.cs @@ -120,4 +120,28 @@ public async Task WhenSelectAsyncSequentialCompletesWhileProcessing_ThenDeferred await Assert.That(done).IsTrue(); await Assert.That(results).IsCollectionEqualTo([Value]); } + + /// Verifies that OnError and a duplicate OnCompleted arriving from + /// the source after the sink has marked itself terminated are silently dropped via the + /// _done || _disposed guard. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenEventsAfterTerminated_ThenDropped() + { + var source = new SyncDirectSource(); + var values = new List(); + Exception? caught = null; + var completedCount = 0; + + using var sub = source.SelectAsyncSequential(static x => Task.FromResult(x)) + .Subscribe(values.Add, ex => caught = ex, () => completedCount++); + + source.Observer.OnCompleted(); + source.Observer.OnError(new InvalidOperationException("late")); + source.Observer.OnCompleted(); + + await Task.Delay(SettleDelayMilliseconds); + await Assert.That(completedCount).IsEqualTo(1); + await Assert.That(caught).IsNull(); + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/SelectLatestAsyncObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/SelectLatestAsyncObservableTests.cs index fb8bf40..ffedf0d 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Operators/SelectLatestAsyncObservableTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/SelectLatestAsyncObservableTests.cs @@ -147,4 +147,29 @@ public async Task WhenSelectLatestAsyncSourceCompletesWithNoValues_ThenForwardsC var done = await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); await Assert.That(done).IsTrue(); } + + /// Verifies that OnNext, OnError and a duplicate OnCompleted + /// arriving after the source has already completed are silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenEventsAfterCompleted_ThenDropped() + { + var source = new SyncDirectSource(); + var values = new List(); + Exception? caught = null; + var completedCount = 0; + + using var sub = source.SelectLatestAsync(static x => Task.FromResult(x)) + .Subscribe(values.Add, ex => caught = ex, () => completedCount++); + + source.Observer.OnCompleted(); + source.Observer.OnNext(1); + source.Observer.OnError(new InvalidOperationException("late")); + source.Observer.OnCompleted(); + + await Task.Delay(SettleDelayMilliseconds); + await Assert.That(completedCount).IsLessThanOrEqualTo(1); + await Assert.That(values).IsEmpty(); + await Assert.That(caught).IsNull(); + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/SimpleSyncOperatorTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/SimpleSyncOperatorTests.cs new file mode 100644 index 0000000..bde9095 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/SimpleSyncOperatorTests.cs @@ -0,0 +1,230 @@ +// 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.Text.RegularExpressions; + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// Edge-case coverage for several small synchronous operators +/// — Shuffle, Filter (regex), TrySelect. +public partial class SimpleSyncOperatorTests +{ + /// Synthetic error message attached to source errors. + private const string SourceErrorMessage = "source error"; + + /// Apple sentinel used by regex-filter tests. + private const string Apple = "apple"; + + /// Shuffle test sentinels. + private const int Shuffle1 = 1; + + /// Shuffle test sentinel. + private const int Shuffle2 = 2; + + /// Shuffle test sentinel. + private const int Shuffle3 = 3; + + /// Shuffle test sentinel. + private const int Shuffle4 = 4; + + /// Shuffle test sentinel. + private const int Shuffle5 = 5; + + /// Inputs used by the Shuffle multiset test. + private static readonly int[] ShuffleInput = [Shuffle1, Shuffle2, Shuffle3, Shuffle4, Shuffle5]; + + /// Verifies that Shuffle preserves the multiset of input values. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenShuffle_ThenPreservesMultiset() + { + var subject = new Subject(); + int[]? shuffled = null; + + using var sub = subject.Shuffle().Subscribe(value => shuffled = value); + + subject.OnNext((int[])ShuffleInput.Clone()); + + await Assert.That(shuffled).IsNotNull(); + var sorted = (int[])shuffled!.Clone(); + Array.Sort(sorted); + await Assert.That(sorted).IsCollectionEqualTo(ShuffleInput); + } + + /// Verifies that Shuffle forwards null arrays unchanged. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenShuffleNullArray_ThenForwardsAsIs() + { + var subject = new Subject(); + var received = 0; + int[]? value = null; + + using var sub = subject.Shuffle().Subscribe(v => + { + received++; + value = v; + }); + + subject.OnNext(null!); + + await Assert.That(received).IsEqualTo(1); + await Assert.That(value).IsNull(); + } + + /// Verifies that Shuffle forwards source errors and disposes the RNG. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenShuffleSourceErrors_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.Shuffle().Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that Shuffle forwards source completion and disposes the RNG. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenShuffleSourceCompletes_ThenForwardsCompletion() + { + var subject = new Subject(); + var completed = false; + + using var sub = subject.Shuffle().Subscribe(static _ => { }, () => completed = true); + + subject.OnCompleted(); + + await Assert.That(completed).IsTrue(); + } + + /// Verifies that Filter with a regex pattern forwards only matching strings. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFilterRegexMatches_ThenForwardsMatching() + { + string[] input = [Apple, "banana", "avocado"]; + var results = new List(); + + using var sub = input.ToObservable() + .Filter("^a") + .Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo([Apple, "avocado"]); + } + + /// Verifies that Filter with a precompiled regex behaves identically. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFilterRegexCompiled_ThenForwardsMatching() + { + string[] input = ["aa", "bb", "ac"]; + var results = new List(); + var regex = StartsWithA(); + + using var sub = input.ToObservable() + .Filter(regex) + .Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo(["aa", "ac"]); + } + + /// Verifies that Filter ignores null inputs without throwing. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFilterNullInput_ThenIgnored() + { + var subject = new Subject(); + var results = new List(); + + using var sub = subject.Filter("^a").Subscribe(results.Add); + + subject.OnNext(null!); + subject.OnNext(Apple); + + await Assert.That(results).IsCollectionEqualTo([Apple]); + } + + /// Verifies that Filter forwards regex exceptions to OnError. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFilterRegexThrows_ThenForwardsError() + { + // A regex with a 1-microsecond timeout against pathological input should throw. + var regex = PathologicalCatastrophicBacktrack(); + var subject = new Subject(); + Exception? caught = null; + + using var sub = subject.Filter(regex).Subscribe(static _ => { }, ex => caught = ex); + + subject.OnNext(new string('a', 100) + "!"); + + await Assert.That(caught).IsNotNull(); + } + + /// Verifies that TrySelect drops null projections and forwards non-nulls. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTrySelectNullProjection_ThenDropped() + { + int[] input = [1, 2, 3, 4]; + var results = new List(); + + using var sub = input.ToObservable() + .TrySelect(static x => x % 2 == 0 ? x.ToString() : null) + .Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo(["2", "4"]); + } + + /// Verifies that an exception thrown by the TrySelect selector is forwarded. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTrySelectThrows_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException("selector failed"); + + using var sub = subject.TrySelect(_ => throw expected) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnNext(1); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that TrySelect forwards source completion. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTrySelectSourceCompletes_ThenForwardsCompletion() + { + var subject = new Subject(); + var completed = false; + + using var sub = subject.TrySelect(static x => x.ToString()) + .Subscribe(static _ => { }, () => completed = true); + + subject.OnCompleted(); + + await Assert.That(completed).IsTrue(); + } + + /// Compiled regex matching strings that begin with the letter 'a'. + /// A compile-time generated instance. + [GeneratedRegex("^a")] + private static partial Regex StartsWithA(); + + /// Compiled regex with catastrophic backtracking and a 1-tick timeout — + /// guaranteed to throw on pathological input. + /// Used to exercise the error-forwarding branch of Filter. + /// A compile-time generated instance with a 1-tick match timeout. + [GeneratedRegex("(a+)+$", RegexOptions.None, matchTimeoutMilliseconds: 1)] + private static partial Regex PathologicalCatastrophicBacktrack(); +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/SubscribeAsyncObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/SubscribeAsyncObservableTests.cs index 8eaf85c..e69a269 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Operators/SubscribeAsyncObservableTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/SubscribeAsyncObservableTests.cs @@ -132,4 +132,58 @@ public async Task WhenSubscribeAsyncDisposed_ThenStopsProcessing() await Assert.That(Volatile.Read(ref handlerRan)).IsEqualTo(0); } + + /// Verifies that OnNext, OnError and a duplicate OnCompleted + /// arriving after the source has already completed are silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenEventsAfterCompleted_ThenDropped() + { + var source = new SyncDirectSource(); + var values = new List(); + Exception? caught = null; + var completedCount = 0; + + using var sub = source.SubscribeSynchronous( + x => + { + values.Add(x); + return Task.CompletedTask; + }, + ex => caught = ex, + () => completedCount++); + + source.Observer.OnCompleted(); + source.Observer.OnNext(1); + source.Observer.OnError(new InvalidOperationException("late")); + source.Observer.OnCompleted(); + + await Task.Delay(SettleDelayMilliseconds); + await Assert.That(completedCount).IsEqualTo(1); + await Assert.That(values).IsEmpty(); + await Assert.That(caught).IsNull(); + } + + /// Verifies that an OnCompleted arriving after a prior OnError is silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnCompletedAfterError_ThenDropped() + { + var source = new SyncDirectSource(); + Exception? caught = null; + var completedCount = 0; + var expected = new InvalidOperationException("first"); + + using var sub = source.SubscribeSynchronous( + static _ => Task.CompletedTask, + ex => caught = ex, + () => completedCount++); + + source.Observer.OnError(expected); + source.Observer.OnCompleted(); + + await Task.Delay(SettleDelayMilliseconds); + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(completedCount).IsEqualTo(0); + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/SyncDirectSource.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/SyncDirectSource.cs new file mode 100644 index 0000000..dbfac6e --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/SyncDirectSource.cs @@ -0,0 +1,30 @@ +// 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. + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// +/// Synchronous test source that hands its observer back to the test so the test can +/// invoke OnNext / OnError / OnCompleted directly — including +/// sequences that would otherwise block (emit-after-complete, +/// double-terminal). Subscriptions return a no-op disposable so external dispose does +/// not detach the observer. +/// +/// The element type. +internal sealed class SyncDirectSource : IObservable +{ + /// The captured observer from the most recent subscription. + private IObserver? _observer; + + /// Gets the captured observer; throws if no one has subscribed yet. + public IObserver Observer => _observer + ?? throw new InvalidOperationException("No observer is currently subscribed."); + + /// + public IDisposable Subscribe(IObserver observer) + { + _observer = observer; + return System.Reactive.Disposables.Disposable.Empty; + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/SyncTimerObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/SyncTimerObservableTests.cs new file mode 100644 index 0000000..0d754fa --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/SyncTimerObservableTests.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 Microsoft.Reactive.Testing; + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// Tests for SyncTimerObservable — covers the mid-array remove path of the +/// shared timer's observer set, the idempotent subscription dispose, and the empty-targets +/// fast-path inside the tick callback. +public class SyncTimerObservableTests +{ + /// Number of periods to advance for the idempotent-dispose assertion. + private const int IdempotentAdvancePeriods = 3; + + /// Verifies that disposing the middle subscription of three keeps the other two ticking. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMiddleObserverDisposed_ThenOthersStillReceiveTicks() + { + var scheduler = new TestScheduler(); + var firstTicks = 0; + var secondTicks = 0; + var thirdTicks = 0; + var period = TimeSpan.FromTicks(100); + + var timer = ReactiveExtensions.SyncTimer(period, scheduler); + + using var subFirst = timer.Subscribe(_ => firstTicks++); + var subSecond = timer.Subscribe(_ => secondTicks++); + using var subThird = timer.Subscribe(_ => thirdTicks++); + + scheduler.AdvanceBy(period.Ticks); + subSecond.Dispose(); + scheduler.AdvanceBy(period.Ticks); + + await Assert.That(firstTicks).IsGreaterThanOrEqualTo(1); + await Assert.That(thirdTicks).IsGreaterThanOrEqualTo(1); + await Assert.That(secondTicks).IsLessThanOrEqualTo(1); + } + + /// Verifies that disposing a subscription twice is idempotent. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscriptionDisposedTwice_ThenIdempotent() + { + var scheduler = new TestScheduler(); + var ticks = 0; + var period = TimeSpan.FromTicks(100); + + var timer = ReactiveExtensions.SyncTimer(period, scheduler); + var sub = timer.Subscribe(_ => ticks++); + + sub.Dispose(); + sub.Dispose(); + + scheduler.AdvanceBy(period.Ticks * IdempotentAdvancePeriods); + + await Assert.That(ticks).IsEqualTo(0); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/SynchronizeAsyncObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/SynchronizeAsyncObservableTests.cs new file mode 100644 index 0000000..0dc82ce --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/SynchronizeAsyncObservableTests.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. + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// Tests for SynchronizeAsyncObservable — covers the after-terminal guards +/// on the sink that only fire when the upstream pushes events past its own completion. +public class SynchronizeAsyncObservableTests +{ + /// Settle delay to confirm nothing fires. + private const int SettleDelayMilliseconds = 50; + + /// Verifies that OnNext, OnError and a duplicate OnCompleted + /// arriving after the source has already completed are silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenEventsAfterCompleted_ThenDropped() + { + var source = new SyncDirectSource(); + var values = new List(); + Exception? caught = null; + var completedCount = 0; + + using var sub = source.SynchronizeAsync() + .Subscribe(t => values.Add(t.Value), ex => caught = ex, () => completedCount++); + + source.Observer.OnCompleted(); + source.Observer.OnNext(1); + source.Observer.OnError(new InvalidOperationException("late")); + source.Observer.OnCompleted(); + + await Task.Delay(SettleDelayMilliseconds); + await Assert.That(completedCount).IsEqualTo(1); + await Assert.That(values).IsEmpty(); + await Assert.That(caught).IsNull(); + } + + /// Verifies that an OnCompleted arriving after a prior OnError is silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnCompletedAfterError_ThenDropped() + { + var source = new SyncDirectSource(); + Exception? caught = null; + var completedCount = 0; + var expected = new InvalidOperationException("first"); + + using var sub = source.SynchronizeAsync() + .Subscribe(static _ => { }, ex => caught = ex, () => completedCount++); + + source.Observer.OnError(expected); + source.Observer.OnCompleted(); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(completedCount).IsEqualTo(0); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/ThrottleDistinctObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/ThrottleDistinctObservableTests.cs new file mode 100644 index 0000000..5ea2a14 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/ThrottleDistinctObservableTests.cs @@ -0,0 +1,61 @@ +// 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 Microsoft.Reactive.Testing; + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// Tests for ThrottleDistinctObservable — the after-terminal guards on the +/// distinct-throttle sink, exercised via a source that pushes events past its own completion. +public class ThrottleDistinctObservableTests +{ + /// Tick window for advancing past the throttle in settle assertions. + private const int SettleTicks = 100; + + /// Tick window for the throttle itself. + private const int ThrottleTicks = 10; + + /// Verifies that an OnNext arriving after completion is silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnNextAfterCompleted_ThenDropped() + { + var scheduler = new TestScheduler(); + var source = new SyncDirectSource(); + var values = new List(); + var completed = false; + + using var sub = source.ThrottleDistinct(TimeSpan.FromTicks(ThrottleTicks), scheduler) + .Subscribe(values.Add, () => completed = true); + + source.Observer.OnCompleted(); + scheduler.AdvanceBy(SettleTicks); + source.Observer.OnNext(1); + scheduler.AdvanceBy(SettleTicks); + + await Assert.That(completed).IsTrue(); + await Assert.That(values).IsEmpty(); + } + + /// Verifies that an OnError arriving after a prior OnCompleted is + /// silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnErrorAfterCompleted_ThenDropped() + { + var scheduler = new TestScheduler(); + var source = new SyncDirectSource(); + Exception? caught = null; + var completed = false; + + using var sub = source.ThrottleDistinct(TimeSpan.FromTicks(ThrottleTicks), scheduler) + .Subscribe(static _ => { }, ex => caught = ex, () => completed = true); + + source.Observer.OnCompleted(); + source.Observer.OnError(new InvalidOperationException("late")); + + await Assert.That(completed).IsTrue(); + await Assert.That(caught).IsNull(); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/ThrottleFirstObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/ThrottleFirstObservableTests.cs index 78a40e3..188fe9f 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Operators/ThrottleFirstObservableTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/ThrottleFirstObservableTests.cs @@ -72,4 +72,28 @@ public async Task WhenThrottleFirstValueAfterError_ThenIgnored() await Assert.That(results).IsEmpty(); } + + /// Verifies that OnNext, OnError and a duplicate OnCompleted + /// arriving after the source has already completed are silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenEventsAfterCompleted_ThenDropped() + { + var source = new SyncDirectSource(); + var values = new List(); + Exception? caught = null; + var completedCount = 0; + + using var sub = source.ThrottleFirst(ThrottleFirstWindow) + .Subscribe(values.Add, ex => caught = ex, () => completedCount++); + + source.Observer.OnCompleted(); + source.Observer.OnNext(1); + source.Observer.OnError(new InvalidOperationException("late")); + source.Observer.OnCompleted(); + + await Assert.That(completedCount).IsEqualTo(1); + await Assert.That(values).IsEmpty(); + await Assert.That(caught).IsNull(); + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/ThrottleObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/ThrottleObservableTests.cs new file mode 100644 index 0000000..b33d148 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/ThrottleObservableTests.cs @@ -0,0 +1,85 @@ +// 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 Microsoft.Reactive.Testing; + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// Tests for ThrottleObservable — the after-terminal guards on +/// OnNext / OnError / OnCompleted that are only reachable when +/// an upstream pushes events past its own completion. +public class ThrottleObservableTests +{ + /// Tick window for advancing past the throttle in settle assertions. + private const int SettleTicks = 100; + + /// Tick window for the throttle itself. + private const int ThrottleTicks = 10; + + /// Verifies that an OnNext arriving after the source has already + /// completed is silently dropped by the throttle sink. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnNextAfterCompleted_ThenDropped() + { + var scheduler = new TestScheduler(); + var source = new SyncDirectSource(); + var values = new List(); + var completed = false; + + using var sub = source.ThrottleOnScheduler(TimeSpan.FromTicks(ThrottleTicks), scheduler) + .Subscribe(values.Add, () => completed = true); + + source.Observer.OnCompleted(); + scheduler.AdvanceBy(SettleTicks); + source.Observer.OnNext(1); + scheduler.AdvanceBy(SettleTicks); + + await Assert.That(completed).IsTrue(); + await Assert.That(values).IsEmpty(); + } + + /// Verifies that an OnError arriving after the source has already + /// completed is silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnErrorAfterCompleted_ThenDropped() + { + var scheduler = new TestScheduler(); + var source = new SyncDirectSource(); + Exception? caught = null; + var completed = false; + + using var sub = source.ThrottleOnScheduler(TimeSpan.FromTicks(ThrottleTicks), scheduler) + .Subscribe(static _ => { }, ex => caught = ex, () => completed = true); + + source.Observer.OnCompleted(); + source.Observer.OnError(new InvalidOperationException("late")); + + await Assert.That(completed).IsTrue(); + await Assert.That(caught).IsNull(); + } + + /// Verifies that an OnCompleted arriving after a prior OnError + /// is silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnCompletedAfterError_ThenDropped() + { + var scheduler = new TestScheduler(); + var source = new SyncDirectSource(); + Exception? caught = null; + var completed = false; + var expected = new InvalidOperationException("first"); + + using var sub = source.ThrottleOnScheduler(TimeSpan.FromTicks(ThrottleTicks), scheduler) + .Subscribe(static _ => { }, ex => caught = ex, () => completed = true); + + source.Observer.OnError(expected); + source.Observer.OnCompleted(); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(completed).IsFalse(); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/ThrottleUntilTrueObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/ThrottleUntilTrueObservableTests.cs index c2c5d36..a2f629a 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Operators/ThrottleUntilTrueObservableTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/ThrottleUntilTrueObservableTests.cs @@ -135,4 +135,28 @@ public async Task WhenThrottleUntilTrueDisposedBeforeFire_ThenNoEmission() await Assert.That(results).IsEmpty(); } + + /// Verifies that OnNext, OnError and a duplicate OnCompleted + /// arriving after the source has already completed are silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenEventsAfterCompleted_ThenDropped() + { + var source = new SyncDirectSource(); + var values = new List(); + Exception? caught = null; + var completedCount = 0; + + using var sub = source.ThrottleUntilTrue(ThrottleWindow, static _ => true) + .Subscribe(values.Add, ex => caught = ex, () => completedCount++); + + source.Observer.OnCompleted(); + source.Observer.OnNext(1); + source.Observer.OnError(new InvalidOperationException("late")); + source.Observer.OnCompleted(); + + await Assert.That(completedCount).IsEqualTo(1); + await Assert.That(values).IsEmpty(); + await Assert.That(caught).IsNull(); + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/UsingActionObservableTests.SecondaryDispose.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/UsingActionObservableTests.SecondaryDispose.cs new file mode 100644 index 0000000..dd7eab1 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/UsingActionObservableTests.SecondaryDispose.cs @@ -0,0 +1,77 @@ +// 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. + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// Covers the secondary-dispose-failure swallow branch and the +/// scheduler-error-forwarding path of UsingActionObservable<T>. +public partial class UsingActionObservableTests +{ + /// Verifies that when the action throws AND the resource also throws on dispose, + /// the primary action exception is forwarded and the dispose failure is swallowed. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenActionAndDisposeBothThrow_ThenPrimaryActionErrorForwardedAndDisposeSwallowed() + { + var resource = new HookDisposable(static () => throw new InvalidOperationException("dispose failed")); + Exception? caught = null; + var actionFailure = new InvalidOperationException("action failed"); + + using var sub = resource.Using(_ => throw actionFailure) + .Subscribe(static _ => { }, ex => caught = ex); + + await Assert.That(caught).IsSameReferenceAs(actionFailure); + await Assert.That(resource.DisposeAttempts).IsEqualTo(1); + } + + /// Verifies that an action exception forwarded via the scheduler path also disposes the resource. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSchedulerPathActionThrows_ThenForwardsErrorAndDisposes() + { + var resource = new CountingDisposable(); + var faulted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var expected = new InvalidOperationException("scheduler action failed"); + + using var sub = resource.Using(_ => throw expected, TaskPoolScheduler.Default) + .Subscribe(static _ => { }, ex => faulted.TrySetResult(ex)); + + var caught = await faulted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(resource.DisposeCount).IsEqualTo(1); + } + + /// Disposable that delegates the side-effect of Dispose to a caller-supplied + /// . Used by tests that intentionally exercise the secondary-failure + /// swallow branch of UsingActionObservable by passing a throwing hook. + private sealed class HookDisposable : IDisposable + { + /// Per-dispose hook invoked from . + private readonly Action _onDispose; + + /// Initializes a new instance of the class. + /// The hook invoked from . + public HookDisposable(Action onDispose) => _onDispose = onDispose; + + /// Gets the number of times was attempted. + public int DisposeAttempts { get; private set; } + + /// + public void Dispose() + { + DisposeAttempts++; + _onDispose(); + } + } + + /// Disposable that simply counts dispose invocations without throwing. + private sealed class CountingDisposable : IDisposable + { + /// Gets the number of times has been invoked. + public int DisposeCount { get; private set; } + + /// + public void Dispose() => DisposeCount++; + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/UsingActionObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/UsingActionObservableTests.cs index 3ce43c3..b915a8f 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Operators/UsingActionObservableTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/UsingActionObservableTests.cs @@ -7,7 +7,7 @@ namespace ReactiveUI.Extensions.Tests.Operators; /// Edge-case coverage for the action-form Using operator backed by /// UsingActionObservable<T> — happy path, null action, scheduler dispatch, /// and action-throws-then-disposes paths. -public class UsingActionObservableTests +public partial class UsingActionObservableTests { /// Synthetic error message attached to action failures. private const string ActionFailedMessage = "action failed"; diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/UsingAndSwitchIfEmptyEdgeTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/UsingAndSwitchIfEmptyEdgeTests.cs index 3ebf0eb..ed2694d 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Operators/UsingAndSwitchIfEmptyEdgeTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/UsingAndSwitchIfEmptyEdgeTests.cs @@ -60,6 +60,15 @@ public async Task WhenUsingActionWithScheduler_ThenRunsOnScheduler() await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); await Assert.That(ran).IsTrue(); + + // OnCompleted is signalled before the resource is disposed on the scheduler + // thread, so spin briefly for the dispose to land. + var deadline = Environment.TickCount64 + 5000; + while (resource.DisposeCount == 0 && Environment.TickCount64 < deadline) + { + await Task.Yield(); + } + await Assert.That(resource.DisposeCount).IsEqualTo(1); } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/UsingFuncObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/UsingFuncObservableTests.cs new file mode 100644 index 0000000..edc3434 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/UsingFuncObservableTests.cs @@ -0,0 +1,98 @@ +// 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. + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// Covers the function-overload Using factory (UsingFuncObservable<T,TResult>). +/// Targets the secondary-dispose-failure swallow branch and the happy-path resource-disposal, +/// matching the methodology used for UsingActionObservable. +public class UsingFuncObservableTests +{ + /// Sentinel result emitted by the happy-path test. + private const int Sentinel = 42; + + /// Verifies the happy path — the function's result is emitted, completion fires, + /// and the resource is disposed exactly once. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFunctionSucceeds_ThenEmitsResultAndDisposesResource() + { + var resource = new CountingDisposable(); + var results = new List(); + var completed = false; + + using var sub = resource.Using(static _ => Sentinel) + .Subscribe(results.Add, () => completed = true); + + await Assert.That(results).IsCollectionEqualTo([Sentinel]); + await Assert.That(completed).IsTrue(); + await Assert.That(resource.DisposeCount).IsEqualTo(1); + } + + /// Verifies the secondary-dispose-failure swallow branch — when the function + /// throws AND the resource also throws on Dispose, the primary function exception is + /// forwarded and the dispose failure is silently swallowed. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFunctionAndDisposeBothThrow_ThenPrimaryErrorForwardedAndDisposeSwallowed() + { + var resource = new HookDisposable(static () => throw new InvalidOperationException("dispose failed")); + Exception? caught = null; + var functionFailure = new InvalidOperationException("function failed"); + + using var sub = resource.Using(new Func(_ => throw functionFailure)) + .Subscribe(static _ => { }, ex => caught = ex); + + await Assert.That(caught).IsSameReferenceAs(functionFailure); + await Assert.That(resource.DisposeAttempts).IsEqualTo(1); + } + + /// Verifies that a function exception forwarded via the scheduler path also disposes the resource. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSchedulerPathFunctionThrows_ThenForwardsErrorAndDisposes() + { + var resource = new CountingDisposable(); + var faulted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var expected = new InvalidOperationException("scheduler function failed"); + + using var sub = resource.Using(new Func(_ => throw expected), TaskPoolScheduler.Default) + .Subscribe(static _ => { }, ex => faulted.TrySetResult(ex)); + + var caught = await faulted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(resource.DisposeCount).IsEqualTo(1); + } + + /// Disposable that delegates Dispose to a caller-supplied . + private sealed class HookDisposable : IDisposable + { + /// Per-dispose hook invoked from . + private readonly Action _onDispose; + + /// Initializes a new instance of the class. + /// The hook invoked from . + public HookDisposable(Action onDispose) => _onDispose = onDispose; + + /// Gets the number of times was attempted. + public int DisposeAttempts { get; private set; } + + /// + public void Dispose() + { + DisposeAttempts++; + _onDispose(); + } + } + + /// Disposable that counts dispose invocations without throwing. + private sealed class CountingDisposable : IDisposable + { + /// Gets the number of times has been invoked. + public int DisposeCount { get; private set; } + + /// + public void Dispose() => DisposeCount++; + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/WaitUntilObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/WaitUntilObservableTests.cs new file mode 100644 index 0000000..68aded3 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/WaitUntilObservableTests.cs @@ -0,0 +1,74 @@ +// 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. + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// Tests for WaitUntilObservable — covers the after-terminal guards +/// on OnNext, OnError, and OnCompleted that fire only when an +/// upstream pushes events past its own completion. +public class WaitUntilObservableTests +{ + /// Verifies that an OnNext arriving after the predicate has already + /// fired and completed the sequence is silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnNextAfterCompleted_ThenDropped() + { + const int Match = 1; + var source = new SyncDirectSource(); + var values = new List(); + var completedCount = 0; + + using var sub = source.WaitUntil(static x => x == Match) + .Subscribe(values.Add, () => completedCount++); + + source.Observer.OnNext(Match); + source.Observer.OnNext(Match); + source.Observer.OnCompleted(); + + await Assert.That(completedCount).IsEqualTo(1); + await Assert.That(values).IsCollectionEqualTo([Match]); + } + + /// Verifies that an OnError arriving after the predicate has fired is + /// silently dropped via the _done guard. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnErrorAfterCompleted_ThenDropped() + { + const int Match = 1; + var source = new SyncDirectSource(); + Exception? caught = null; + var completedCount = 0; + + using var sub = source.WaitUntil(static x => x == Match) + .Subscribe(static _ => { }, ex => caught = ex, () => completedCount++); + + source.Observer.OnNext(Match); + source.Observer.OnError(new InvalidOperationException("late")); + + await Assert.That(completedCount).IsEqualTo(1); + await Assert.That(caught).IsNull(); + } + + /// Verifies that a duplicate OnCompleted after an error is silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnCompletedAfterError_ThenDropped() + { + var source = new SyncDirectSource(); + Exception? caught = null; + var completedCount = 0; + var expected = new InvalidOperationException("first"); + + using var sub = source.WaitUntil(static _ => false) + .Subscribe(static _ => { }, ex => caught = ex, () => completedCount++); + + source.Observer.OnError(expected); + source.Observer.OnCompleted(); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(completedCount).IsEqualTo(0); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/ReactiveExtensionsTests.Misc.cs b/src/tests/ReactiveUI.Extensions.Tests/ReactiveExtensionsTests.Misc.cs index fc7993d..f42381a 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/ReactiveExtensionsTests.Misc.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/ReactiveExtensionsTests.Misc.cs @@ -13,6 +13,9 @@ public partial class ReactiveExtensionsTests /// String literal "initial" used by multiple tests. private const string InitialValueLiteral = "initial"; + /// Stabilization window for scheduler-driven assertions. + private const int SchedulerStabilizeMilliseconds = 100; + /// Hoisted source array used by tests (was inline literal). private static readonly string[] SequenceTest123HelloTest456World = ["test123", "hello", "test456", "world"]; @@ -613,6 +616,187 @@ public async Task WhenToPropertyObservableDisposed_ThenUnsubscribesFromPropertyC await Assert.That(results).IsCollectionEqualTo([InitialValueLiteral, ChangedValueLiteral]); } + /// Verifies ScheduleSafe(action) uses the scheduler when one is supplied. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScheduleSafeImmediateWithScheduler_ThenSchedulerIsUsed() + { + var scheduler = new Microsoft.Reactive.Testing.TestScheduler(); + var ran = false; + + scheduler.ScheduleSafe(() => ran = true); + + await Assert.That(ran).IsFalse(); + scheduler.AdvanceBy(1); + await Assert.That(ran).IsTrue(); + } + + /// Verifies ScheduleSafe(dueTime, action) uses the scheduler when one is supplied. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScheduleSafeDelayedWithScheduler_ThenSchedulerIsUsed() + { + const int DelayTicks = 50; + var scheduler = new Microsoft.Reactive.Testing.TestScheduler(); + var ran = false; + + scheduler.ScheduleSafe(TimeSpan.FromTicks(DelayTicks), () => ran = true); + + await Assert.That(ran).IsFalse(); + scheduler.AdvanceBy(DelayTicks); + await Assert.That(ran).IsTrue(); + } + + /// Verifies the two-argument OnErrorRetry<TSource,TException> overload retries indefinitely. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnErrorRetryTypedTwoArgOverload_ThenRetriesAndCallsErrorHandler() + { + const int SuccessAttempt = 2; + var attempts = 0; + var values = new List(); + var caught = new List(); + var source = Observable.Create(observer => + { + var attempt = Interlocked.Increment(ref attempts); + observer.OnNext(attempt); + if (attempt < SuccessAttempt) + { + observer.OnError(new InvalidOperationException("retry")); + } + else + { + observer.OnCompleted(); + } + + return System.Reactive.Disposables.Disposable.Empty; + }); + + using var sub = source.OnErrorRetry(caught.Add).Subscribe(values.Add); + + await Assert.That(values).IsCollectionEqualTo([1, SuccessAttempt]); + await Assert.That(caught).Count().IsEqualTo(1); + } + + /// Verifies the typed OnErrorRetry<TSource,TException> skips the error callback when the exception type does not match. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnErrorRetryNonMatchingExceptionType_ThenOnErrorCallbackSkipped() + { + var caught = new List(); + var values = new List(); + var failure = new InvalidOperationException("wrong type"); + + var source = Observable.Create(observer => + { + observer.OnNext(1); + observer.OnError(failure); + return System.Reactive.Disposables.Disposable.Empty; + }); + + using var sub = source.OnErrorRetry(caught.Add, retryCount: 1, TimeSpan.Zero, Scheduler.Default) + .Subscribe(values.Add, static _ => { }); + + await Task.Delay(TimeSpan.FromMilliseconds(SchedulerStabilizeMilliseconds)); + + await Assert.That(caught).IsEmpty(); + await Assert.That(values.Count).IsGreaterThanOrEqualTo(1); + } + + /// Verifies the two-argument RetryWithBackoff overload retries until success. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRetryWithBackoffTwoArgOverload_ThenRetriesUntilSuccess() + { + const int SuccessAttempt = 2; + var attempts = 0; + var done = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + var values = new List(); + var source = Observable.Create(observer => + { + var attempt = Interlocked.Increment(ref attempts); + if (attempt < SuccessAttempt) + { + observer.OnError(new InvalidOperationException("retry")); + } + else + { + observer.OnNext(attempt); + observer.OnCompleted(); + } + + return System.Reactive.Disposables.Disposable.Empty; + }); + + using var sub = source.RetryWithBackoff(maxRetries: 3, TimeSpan.FromMilliseconds(1)) + .Subscribe(values.Add, () => done.TrySetResult(values)); + + var captured = await done.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(captured).IsCollectionEqualTo([SuccessAttempt]); + } + + /// Verifies ReplayLastOnSubscribe throws when the source is null. + [Test] + public void WhenReplayLastOnSubscribeSourceNull_ThenThrows() => + Assert.Throws(static () => ReactiveExtensions.ReplayLastOnSubscribe(null!, 0)); + + /// Verifies the two-argument BufferUntilInactive overload flushes a buffer on completion using the default scheduler. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenBufferUntilInactiveTwoArgOverload_ThenFlushesBufferOnCompletion() + { + var subject = new Subject(); + var results = new List>(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = subject.BufferUntilInactive(TimeSpan.FromSeconds(5)) + .Subscribe(results.Add, () => completed.TrySetResult()); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + subject.OnCompleted(); + + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(results.Count).IsGreaterThanOrEqualTo(1); + await Assert.That(results[^1]).IsCollectionEqualTo([1, SampleValue2]); + } + + /// Verifies CatchReturn substitutes the fallback value when the source errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCatchReturnSourceErrors_ThenEmitsFallbackAndCompletes() + { + const int Fallback = 99; + var subject = new Subject(); + var results = new List(); + var completed = false; + + using var sub = subject.CatchReturn(Fallback).Subscribe(results.Add, () => completed = true); + + subject.OnNext(1); + subject.OnError(new InvalidOperationException("boom")); + + await Assert.That(results).IsCollectionEqualTo([1, Fallback]); + await Assert.That(completed).IsTrue(); + } + + /// Verifies CatchReturnUnit substitutes when the source errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCatchReturnUnitSourceErrors_ThenEmitsUnitAndCompletes() + { + var subject = new Subject(); + var results = new List(); + var completed = false; + + using var sub = subject.CatchReturnUnit().Subscribe(results.Add, () => completed = true); + + subject.OnError(new InvalidOperationException("boom")); + + await Assert.That(results).IsCollectionEqualTo([Unit.Default]); + await Assert.That(completed).IsTrue(); + } + /// /// Test class for INotifyPropertyChanged. /// diff --git a/src/tests/ReactiveUI.Extensions.Tests/ReactiveExtensionsTests.Throttle.cs b/src/tests/ReactiveUI.Extensions.Tests/ReactiveExtensionsTests.Throttle.cs index 3b62e14..acde969 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/ReactiveExtensionsTests.Throttle.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/ReactiveExtensionsTests.Throttle.cs @@ -370,4 +370,39 @@ public async Task WhenDebounceUntilWithoutScheduler_ThenEmitsImmediatelyWhenCond await received.Task.WaitAsync(TimeSpan.FromSeconds(5)); await Assert.That(results).Contains(SampleValue2); } + + /// Verifies that DebounceUntil forwards source completion downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDebounceUntilSourceCompletes_ThenForwardsCompletion() + { + var scheduler = new TestScheduler(); + var subject = new Subject(); + var completed = false; + + using var sub = subject.DebounceUntil(TimeSpan.FromTicks(SchedulerWindowTicks), static _ => true, scheduler) + .Subscribe(static _ => { }, () => completed = true); + + subject.OnCompleted(); + + await Assert.That(completed).IsTrue(); + } + + /// Verifies that DebounceUntil forwards source errors downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDebounceUntilSourceErrors_ThenForwardsError() + { + var scheduler = new TestScheduler(); + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException("source-failed"); + + using var sub = subject.DebounceUntil(TimeSpan.FromTicks(SchedulerWindowTicks), static _ => true, scheduler) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } }