diff --git a/README.md b/README.md index 2ba4a37..ad0fcab 100644 --- a/README.md +++ b/README.md @@ -16,18 +16,21 @@ ReactiveUI.Primitives is designed to: ## Table of contents 1. [Install](#install) -2. [Target frameworks and dependencies](#target-frameworks-and-dependencies) -3. [Core model](#core-model) -4. [Creation factories](#creation-factories) -5. [Operators](#operators) -6. [Stateful signals and subject-like types](#stateful-signals-and-subject-like-types) -7. [Sequencers](#sequencers) -8. [Threading, disposal, and error semantics](#threading-disposal-and-error-semantics) -9. [Source-generator bridge behavior](#source-generator-bridge-behavior) -10. [Migration guides](#systemreactive-to-reactiveuiprimitives-migration-guide) -11. [Benchmarks and performance posture](#benchmarks-and-performance-posture) -12. [Repository layout](#repository-layout) -13. [Validation commands](#validation-commands) +2. [Agent Skills](#agent-skills) +3. [Target frameworks and dependencies](#target-frameworks-and-dependencies) +4. [Core model](#core-model) +5. [Creation factories](#creation-factories) +6. [Operators](#operators) +7. [ReactiveUI.Primitives.Async](#reactiveuiprimitivesasync) +8. [ReactiveUI.Primitives.Extensions](#reactiveuiprimitivesextensions) +9. [Stateful signals and subject-like types](#stateful-signals-and-subject-like-types) +10. [Sequencers](#sequencers) +11. [Threading, disposal, and error semantics](#threading-disposal-and-error-semantics) +12. [Source-generator bridge behavior](#source-generator-bridge-behavior) +13. [Migration guides](#systemreactive-to-reactiveuiprimitives-migration-guide) +14. [Benchmarks and performance posture](#benchmarks-and-performance-posture) +15. [Repository layout](#repository-layout) +16. [Validation commands](#validation-commands) ## Install @@ -37,9 +40,11 @@ When the package is available on your configured NuGet feed: dotnet add package ReactiveUI.Primitives ``` -Optional UI/platform integration packages are split out so the base package stays free of UI framework references: +Optional Async, Extensions, and UI/platform integration packages are split out so the base package stays free of async-helper and UI framework references: ```bash +dotnet add package ReactiveUI.Primitives.Async +dotnet add package ReactiveUI.Primitives.Extensions dotnet add package ReactiveUI.Primitives.Wpf dotnet add package ReactiveUI.Primitives.WinForms dotnet add package ReactiveUI.Primitives.WinUI @@ -51,8 +56,11 @@ Then import the namespaces you need: ```csharp using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Async; using ReactiveUI.Primitives.Concurrency; using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions; +using ReactiveUI.Primitives.Async.Signals; using ReactiveUI.Primitives.Signals; ``` @@ -63,6 +71,36 @@ The package metadata is configured to include this README in the NuGet package v Those generators are analyzers. They do not add runtime System.Reactive or R3 dependencies to ReactiveUI.Primitives. They emit bridge code only when the consuming compilation already references the relevant external library symbols. +## Agent Skills + +The base `ReactiveUI.Primitives` NuGet package includes `Skills.md` at the package root. It is an agent-oriented guide for using ReactiveUI.Primitives, Async, Extensions, UI sequencers, bridge source generators, and migration from System.Reactive or R3 while assuming the libraries are consumed from NuGet packages. + +After package restore, locate the file in the local NuGet package cache: + +```powershell +$version = "" +$skill = "$env:USERPROFILE\.nuget\packages\reactiveui.primitives\$version\Skills.md" +``` + +On macOS or Linux: + +```bash +version="" +skill="$HOME/.nuget/packages/reactiveui.primitives/$version/Skills.md" +``` + +Install the skill by copying the contents of `Skills.md` into the instruction location supported by the agent. Agents that expect a `SKILL.md` file should use a `reactiveui-primitives` directory and rename the copied file to `SKILL.md`. + +| Agent | Recommended project-local install | Notes | +|---|---|---| +| [OpenAI Codex](https://developers.openai.com/codex/skills) | `.agents/skills/reactiveui-primitives/SKILL.md` | Codex also supports user-level skills under `$HOME/.agents/skills`. | +| [Claude Code](https://code.claude.com/docs/en/skills) | `.claude/skills/reactiveui-primitives/SKILL.md` | Claude Code also supports personal skills under `~/.claude/skills`. | +| [Cline](https://docs.cline.bot/customization/skills) | `.cline/skills/reactiveui-primitives/SKILL.md` | Cline skills must be enabled in Cline's feature settings. | +| [GitHub Copilot](https://docs.github.com/en/copilot/concepts/prompting/response-customization) | `.github/instructions/reactiveui-primitives.instructions.md` | For repository-wide behavior, summarize or link the skill from `.github/copilot-instructions.md`. | +| [Cursor](https://docs.cursor.com/en/context) | `.cursor/rules/reactiveui-primitives.mdc` | Cursor project rules are version-controlled under `.cursor/rules`; `AGENTS.md` is also supported. | +| [Windsurf](https://docs.windsurf.com/windsurf/cascade/memories) | `.windsurf/rules/reactiveui-primitives.md` | Windsurf also reads `AGENTS.md` through the same rules engine. | +| [Gemini CLI](https://google-gemini.github.io/gemini-cli/docs/cli/gemini-md.html) | `GEMINI.md` or an imported file referenced from `GEMINI.md` | Gemini CLI loads hierarchical context files and supports importing other markdown files with `@file.md`. | + ## Target frameworks and dependencies The base production `ReactiveUI.Primitives` library uses `$(LibraryTargetFrameworks)` from `src/Directory.Build.props` and currently targets: @@ -72,19 +110,22 @@ The base production `ReactiveUI.Primitives` library uses `$(LibraryTargetFramewo - `net10.0` - `net462` - `net472` +- `net48` - `net481` Windows UI and platform-integration projects in this repository use their own TFM properties (for example `net8.0-windows`, `net9.0-windows`, `net10.0-windows`, or MAUI/platform-focused TFMs where applicable). Those platform TFMs are not target frameworks of the base `ReactiveUI.Primitives` package. The optional package TFMs are: -- `ReactiveUI.Primitives.Wpf`: `net8.0-windows`, `net9.0-windows`, `net10.0-windows`, `net462`, `net472`, `net481` -- `ReactiveUI.Primitives.WinForms`: `net8.0-windows`, `net9.0-windows`, `net10.0-windows`, `net462`, `net472`, `net481` +- `ReactiveUI.Primitives.Wpf`: `net8.0-windows`, `net9.0-windows`, `net10.0-windows`, `net462`, `net472`, `net48`, `net481` +- `ReactiveUI.Primitives.WinForms`: `net8.0-windows`, `net9.0-windows`, `net10.0-windows`, `net462`, `net472`, `net48`, `net481` - `ReactiveUI.Primitives.WinUI`: `net8.0-windows10.0.19041.0`, `net9.0-windows10.0.19041.0`, `net10.0-windows10.0.19041.0` - `ReactiveUI.Primitives.Blazor`: `net8.0`, `net9.0`, `net10.0` - `ReactiveUI.Primitives.Maui`: `net9.0`, `net10.0` +- `ReactiveUI.Primitives.Async`: `net8.0`, `net9.0`, `net10.0`, `net462`, `net472`, `net48`, `net481` +- `ReactiveUI.Primitives.Extensions`: `net8.0`, `net9.0`, `net10.0`, `net462`, `net472`, `net48`, `net481` -Runtime package dependencies are intentionally small. The base production package does not depend on System.Reactive or R3. The only runtime package reference declared directly by `src/ReactiveUI.Primitives/ReactiveUI.Primitives.csproj` is `System.ValueTuple` for `net462`; the bridge source generators are packed as analyzers in the base package rather than shipped as separate NuGet packages. `ReactiveUI.Primitives.Blazor` references `Microsoft.AspNetCore.Components`, `ReactiveUI.Primitives.Maui` references `Microsoft.Maui.Core`, and `ReactiveUI.Primitives.WinUI` references `Microsoft.WindowsAppSDK`. The remaining shared package references are analyzer, SourceLink, versioning, ILLink, reference-assembly, or build-time support packages such as Blazor.Common.Analyzers, Microsoft.SourceLink.GitHub, MinVer, Roslynator.Analyzers, SonarAnalyzer.CSharp, stylecop.analyzers, Microsoft.NET.ILLink.Tasks, and Microsoft.NETFramework.ReferenceAssemblies. Benchmark projects may reference System.Reactive and R3 as comparison baselines, but those references are not production dependencies. +Runtime package dependencies are intentionally small. The base production package does not depend on System.Reactive or R3. The only runtime package reference declared directly by `src/ReactiveUI.Primitives/ReactiveUI.Primitives.csproj` is `System.ValueTuple` for `net462`; the bridge source generators are packed as analyzers in the base package rather than shipped as separate NuGet packages. `ReactiveUI.Primitives.Async` and `ReactiveUI.Primitives.Extensions` reference `ReactiveUI.Primitives`; their additional package references are limited to .NET Framework compatibility/support packages such as `System.ValueTuple`, Polyfill, Microsoft.Bcl.TimeProvider, System.Threading.Channels, System.Runtime.CompilerServices.Unsafe, System.ComponentModel.Annotations, System.Buffers, System.Memory, and System.Collections.Immutable for `net4x` targets. `ReactiveUI.Primitives.Async` also packs the bridge source generators as analyzers so async bridge methods are generated for consumers that reference System.Reactive or R3. `ReactiveUI.Primitives.Extensions` has no production System.Reactive or R3 dependency. `ReactiveUI.Primitives.Blazor` references `Microsoft.AspNetCore.Components`, `ReactiveUI.Primitives.Maui` references `Microsoft.Maui.Core`, and `ReactiveUI.Primitives.WinUI` references `Microsoft.WindowsAppSDK`. The remaining shared package references are analyzer, SourceLink, versioning, ILLink, reference-assembly, or build-time support packages such as Blazor.Common.Analyzers, Microsoft.SourceLink.GitHub, MinVer, Roslynator.Analyzers, SonarAnalyzer.CSharp, stylecop.analyzers, Microsoft.NET.ILLink.Tasks, and Microsoft.NETFramework.ReferenceAssemblies. Benchmark projects may reference System.Reactive, R3, and ReactiveUI.Extensions as comparison baselines, but those references are not production dependencies. ## Core model @@ -366,6 +407,163 @@ IObservable> sparks = Signal.Sequence(1, 3).Spark(); IObservable values = sparks.Unspark(); ``` +## ReactiveUI.Primitives.Async + +`ReactiveUI.Primitives.Async` is the async counterpart to the base `ReactiveUI.Primitives` surface. It keeps the Primitives vocabulary and adds `ValueTask`/`CancellationToken`-aware observer calls for producers and consumers that need asynchronous notification, asynchronous disposal, or async stream collection. + +Core async contracts and data types: + +| API | Purpose | +|---|---| +| `IObservableAsync` | Async observable contract. `SubscribeAsync` receives an `IObserverAsync` and returns an `IAsyncDisposable`. | +| `IObserverAsync` | Async observer contract with `OnNextAsync`, `OnErrorResumeAsync`, `OnCompletedAsync`, and `DisposeAsync`. | +| `ObserverAsync` | Base observer type for implementing async observers. | +| `ISignalAsync` | Pushable async signal that combines `IObserverAsync`, `IObservableAsync`, and a `Values` observable. | +| `SignalAsync` | Abstract base and static factory/operator host for async observables. | +| `ConnectableSignalAsync` | Async connectable sequence returned by multicast/publish operators. | +| `Result` | Completion result that represents success or terminal failure. | +| `Optional` | Allocation-free optional value used by replay/latest async signals. | +| `AsyncContext` | Dispatch abstraction over `SynchronizationContext`, `TaskScheduler`, or `ISequencer`. | +| `ConcurrentObserverCallsException` | Raised when a serial signal detects concurrent observer calls. | +| `UnhandledExceptionHandler` | Central handler for async fire-and-forget failures. | + +Async signal factories live in two places. Use `ReactiveUI.Primitives.Async.Signals.Signal` when you need a mutable signal, and use `SignalAsync` when you need a sequence factory or operator: + +| Factory group | APIs | +|---|---| +| Mutable signals | `Signal.Create()`, `Signal.Create(SignalCreationOptions)`, `Signal.CreateBehavior(startValue)`, `Signal.CreateBehavior(startValue, BehaviorSignalCreationOptions)`, `Signal.CreateReplayLatest()`, `Signal.CreateReplayLatest(ReplayLatestSignalCreationOptions)` | +| Signal options | `SignalCreationOptions`, `BehaviorSignalCreationOptions`, `ReplayLatestSignalCreationOptions`, `PublishingOption` | +| Stateless factories | `SignalAsync.Emit`, `EmitRxVoid`, `None`, `Fail`, `Return`, `Empty`, `Never`, `Throw` | +| Sequence factories | `Sequence`, `Range`, `FromEnumerable`, `FromAsyncEnumerable`, `ToAsyncSignal`, `Create`, `CreateAsBackgroundJob`, `Defer`, `FromAsync`, `Use`, `Using` | +| Time factories | `After`, `Every`, `Pulse`, `Timer`, `Interval` | +| Async disposables | `DisposableAsync.Empty`, `DisposableAsync.Create`, `DisposableAsyncSlot`, `SingleAssignmentDisposableAsync`, `SingleReplaceableDisposableAsync`, `MultipleDisposableAsync` | + +Async operators follow the same naming style as the core package where that avoids collisions with System.Reactive/R3, while preserving familiar aliases for compatibility: + +| Category | APIs | +|---|---| +| Projection/filtering | `Map`, `MapWith`, `Keep`, `KeepWith`, `KeepNotNull`, `KeepType`, `CastTo`, `Select`, `Where`, `OfType`, `Cast`, `Tap`, `Do`, `Fold`, `Scan`, `ReduceAsync`, `AggregateAsync`, `Distinct`, `Unique`, `DistinctBy`, `UniqueBy`, `DistinctUntilChanged`, `DistinctUntilChangedBy`, `SkipWhileNull`, `WhereIsNotNull`, `WhereTrue`, `WhereFalse`, `Not`, `GetMin`, `GetMax`, `ForEach` | +| Composition | `Bind`, `FlatMap`, `SelectMany`, `Chain`, `Concat`, `Blend`, `Merge`, `SwitchTo`, `Switch`, `Pair`, `Zip`, `SyncLatest`, `PairLatest`, `CombineLatest`, `CombineLatestValuesAreAllTrue`, `CombineLatestValuesAreAllFalse`, `GroupBy` | +| Error/retry/recovery | `Reattempt`, `Retry`, `Recover`, `Rescue`, `Resume`, `Catch`, `OnErrorResumeAsFailure` | +| Time/scheduling | `Shift`, `Delay`, `Expire`, `Timeout`, `Throttle`, `ObserveOn`, `Yield` | +| Lifetime/multicast | `Multicast`, `Publish`, `StatelessPublish`, `ReplayLatestPublish`, `StatelessReplayLatestPublish`, `RefCount`, `OnDispose`, `TakeUntil`, `TakeUntilOptions`, `CompletionSignalDelegate`, `Wrap` | +| Sequence boundaries | `Take`, `Skip`, `TakeWhile`, `SkipWhile`, `Lead`, `Prepend`, `StartWith` | +| Terminal helpers | `FirstAsync`, `FirstOrDefaultAsync`, `LastAsync`, `LastOrDefaultAsync`, `SingleAsync`, `SingleOrDefaultAsync`, `AnyAsync`, `AllAsync`, `ContainsAsync`, `CountAsync`, `LongCountAsync`, `ToListAsync`, `CollectListAsync`, `CollectArrayAsync`, `ToDictionaryAsync`, `ToAsyncEnumerable`, `WaitCompletionAsync`, `ForEachAsync`, `SubscribeAsync` | + +Basic async sequence example: + +```csharp +using ReactiveUI.Primitives.Async; + +List labels = await SignalAsync.Sequence(1, 12) + .Keep(static value => value % 2 == 0) + .Map(static value => $"even:{value}") + .ToListAsync(); +``` + +Mutable async signal example: + +```csharp +using ReactiveUI.Primitives.Async; +using ReactiveUI.Primitives.Async.Signals; + +ISignalAsync requests = Signal.Create(); + +await using IAsyncDisposable subscription = await requests.Values + .Map(static value => value * 2) + .SubscribeAsync(value => Console.WriteLine(value)); + +await requests.OnNextAsync(21, CancellationToken.None); +await requests.OnCompletedAsync(Result.Success); +``` + +Async context example: + +```csharp +using ReactiveUI.Primitives.Async; + +AsyncContext context = AsyncContext.From(TaskScheduler.Default); + +await using IAsyncDisposable subscription = await SignalAsync.Sequence(1, 3) + .ObserveOn(context) + .SubscribeAsync(static value => Console.WriteLine(value)); +``` + +`ReactiveUI.Primitives.Async` also packs the bridge source generators as analyzers. A consumer that references System.Reactive can use generated `ToObservableAsync(this System.IObservable)` and `ToObservable(this IObservableAsync)` adapters. A consumer that references R3 can use generated `AsPrimitivesAsyncObservable(this R3.Observable)` and `AsR3Observable(this IObservableAsync)` adapters. + +## ReactiveUI.Primitives.Extensions + +`ReactiveUI.Primitives.Extensions` migrates the non-async helper surface from `ReactiveUI.Extensions` onto `ReactiveUI.Primitives`. It is still based on the BCL `IObservable` contract, but it uses `ISequencer` for scheduling and production references only `ReactiveUI.Primitives` plus framework compatibility packages. It does not reference System.Reactive or R3. + +Core utility surface: + +| API | Purpose | +|---|---| +| `Heartbeat` / `IHeartbeat` | Value plus heartbeat metadata from heartbeat operators. | +| `Stale` / `IStale` | Value plus stale/fresh state from stale-detection operators. | +| `Continuation` | Disposable continuation helper for bridging synchronous waits. | +| `Observables.Return(value)` | Single-value observable factory. | +| `ObserverExtensions.FastForEach` | Pushes enumerable values into an observer with array/list fast paths. | +| `ObservableSubscriptionExtensions` | Synchronous test/utility helpers: `SubscribeGetValue`, `SubscribeAndComplete`, `SubscribeGetError`, `WaitForValue`, `WaitForCompletion`, `WaitForError`. | + +Extension operators are grouped below by feature area: + +| Category | APIs | +|---|---| +| Filtering/projection | `WhereIsNotNull`, `SkipWhileNull`, `Not`, `WhereTrue`, `WhereFalse`, `WhereSelect`, `SelectConstant`, `TrySelect`, `SelectManyThen`, `Pairwise`, `Partition`, `Filter`, `ForEach`, `Shuffle`, `LatestOrDefault`, `GetMin`, `GetMax`, `CombineLatestValuesAreAllTrue`, `CombineLatestValuesAreAllFalse` | +| Error/retry | `CatchIgnore`, `CatchAndReturn`, `CatchReturn`, `CatchReturnUnit`, `LogErrors`, `OnErrorRetry`, `RetryWithBackoff`, `RetryWithDelay`, `RetryForeverWithDelay`, `RetryWithFixedDelay` | +| Time/scheduling | `SyncTimer`, `ObserveOnIf`, `ScheduleSafe`, `Schedule`, `SampleLatest`, `DetectStale`, `Conflate`, `Heartbeat`, `ThrottleFirst`, `ThrottleUntilTrue`, `ThrottleOnScheduler`, `ThrottleDistinct`, `DebounceImmediate`, `DebounceUntil`, `WaitUntil` | +| Buffer/collection | `BufferUntil`, `BufferUntilIdle`, `BufferUntilInactive`, `FromArray`, `RunAll`, `FirstMatchFromCandidates` | +| Async/sync interaction | `SynchronizeSynchronous`, `SubscribeSynchronous`, `SynchronizeAsync`, `SubscribeAsync`, `SelectAsync`, `SelectAsyncSequential`, `SelectLatestAsync`, `SelectAsyncConcurrent`, `DropIfBusy`, `WithLimitedConcurrency` | +| State/property/lifetime | `AsSignal`, `ToReadOnlyBehavior`, `ReplayLastOnSubscribe`, `SwitchIfEmpty`, `TakeUntil`, `Start`, `Using`, `While`, `ScanWithInitial`, `ToHotTask`, `ToHotValueTask`, `ToPropertyObservable`, `OnNext(params)`, `DoOnSubscribe`, `DoOnDispose` | + +Filtering and projection example: + +```csharp +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Extensions; +using ReactiveUI.Primitives.Signals; + +IObservable labels = Signal.Sequence(1, 10) + .WhereSelect( + static value => value % 2 == 0, + static value => $"even:{value}"); + +using IDisposable subscription = labels.Subscribe(Console.WriteLine); +``` + +Scheduling example: + +```csharp +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Extensions; + +ISequencer sequencer = ThreadPoolSequencer.Instance; + +using IDisposable work = "ready" + .Schedule(TimeSpan.FromMilliseconds(50), sequencer) + .Subscribe(Console.WriteLine); +``` + +Async selector example over a BCL observable: + +```csharp +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Extensions; +using ReactiveUI.Primitives.Signals; + +IObservable names = Signal.Sequence(1, 3) + .SelectAsyncSequential(static async value => + { + await Task.Yield(); + return $"item:{value}"; + }); + +using IDisposable subscription = names.Subscribe(Console.WriteLine); +``` + +The Extensions project is intended for applications that already use the helper operators from `ReactiveUI.Extensions` and want the same shapes without pulling System.Reactive or R3 into the production dependency graph. + ## Stateful signals and subject-like types ReactiveUI.Primitives uses explicit names instead of cloning every System.Reactive subject type name. @@ -498,16 +696,21 @@ Generated System.Reactive bridge methods: - `AsSystemObservable(this System.IObservable source)` - `AsSequencer(this System.Reactive.Concurrency.IScheduler scheduler)` - `AsSystemScheduler(this ReactiveUI.Primitives.Concurrency.ISequencer sequencer)` +- `ToObservableAsync(this System.IObservable source)` when `ReactiveUI.Primitives.Async` is referenced +- `ToObservable(this ReactiveUI.Primitives.Async.IObservableAsync source)` when `ReactiveUI.Primitives.Async` is referenced Generated R3 bridge methods: - `AsPrimitivesSignal(this R3.Observable source)` - `AsR3Observable(this System.IObservable source)` +- `AsPrimitivesAsyncObservable(this R3.Observable source)` when `ReactiveUI.Primitives.Async` is referenced +- `AsR3Observable(this ReactiveUI.Primitives.Async.IObservableAsync source)` when `ReactiveUI.Primitives.Async` is referenced System.Reactive bridge example, when the consuming project already references System.Reactive: ```csharp using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Async; using ReactiveUI.Primitives.Signals; using ReactiveUI.Primitives.SystemReactiveBridge; using System.Reactive.Linq; @@ -520,6 +723,9 @@ using var subscription = PrimitivesSource .Subscribe(Console.WriteLine); IObservable systemObservable = Signal.Sequence(1, 3).AsSystemObservable(); + +IObservableAsync asyncSource = rxSource.ToObservableAsync(); +IObservable rxAgain = asyncSource.ToObservable(); ``` The scheduler bridge is a compatibility boundary. Its generated adapters carry the recursive `IScheduler.Schedule` callback and `IDisposable` return shape so native `ISequencer`/`IWorkItem` paths stay on the lean core scheduler contract. @@ -659,13 +865,28 @@ R3 uses its own `Observable` type and observer model. ReactiveUI.Primitives s | R3 subject | `Signal` / `StateSignal` / `HistorySignal` depending on state/replay needs. | | R3 `Select` / `Where` | `Map` / `Keep`. | | R3 time operators | `Signal.After`, `Signal.Pulse`, `Calm`, `Probe`, `Shift`, scheduler overloads. | -| R3 bridge | Generated `AsPrimitivesSignal` / `AsR3Observable` when R3 is referenced by the consumer. | +| R3 bridge | Generated `AsPrimitivesSignal` / `AsR3Observable`; async bridge methods add `AsPrimitivesAsyncObservable` / `AsR3Observable` when R3 and `ReactiveUI.Primitives.Async` are referenced by the consumer. | Use the generated bridge only at boundaries. Prefer native ReactiveUI.Primitives operators inside new code. +## ReactiveUI.Extensions migration notes + +`ReactiveUI.Primitives.Extensions` is the migration target for the non-async helpers that previously lived in `ReactiveUI.Extensions`. The package intentionally keeps the helper names where those names already describe the behavior and do not collide with the core Primitives vocabulary. Scheduling overloads use `ISequencer` instead of System.Reactive schedulers. + +| ReactiveUI.Extensions usage | ReactiveUI.Primitives.Extensions usage | +|---|---| +| `WhereIsNotNull`, `SkipWhileNull`, `WhereTrue`, `WhereFalse`, `Not` | Same names over BCL `IObservable`. | +| `WhereSelect`, `SelectConstant`, `TrySelect`, `SelectManyThen`, `Pairwise`, `Partition` | Same helper names; implemented with direct observers and fused operator shapes where useful. | +| `SyncTimer`, `ObserveOnIf`, `Schedule`, `ScheduleSafe`, throttle/debounce helpers | Same helper names; use `ISequencer` overloads for scheduling. | +| `CatchIgnore`, `CatchAndReturn`, `CatchReturn`, retry helpers | Same helper names; no System.Reactive dependency. | +| `SubscribeAsync`, `SelectAsync`, `SelectLatestAsync`, `DropIfBusy` | Same BCL observable helper names for Task/ValueTask interop. | +| `RunAll`, `BufferUntil`, `FirstMatchFromCandidates`, `ToHotTask`, `ToHotValueTask` | Same helper names; backed by ReactiveUI.Primitives runtime utilities. | + +For async-native streams, prefer `ReactiveUI.Primitives.Async` and its `IObservableAsync` operators. For existing BCL observable helpers, migrate to `ReactiveUI.Primitives.Extensions`. + ## Benchmarks and performance posture -Benchmarks live in `src/benchmarks/ReactiveUI.Primitives.Benchmarks`. The benchmark project may reference System.Reactive and R3 to compare throughput and allocation behavior; the production package must not. +Benchmarks live in `src/benchmarks/ReactiveUI.Primitives.Benchmarks`. The benchmark project may reference System.Reactive, R3, and ReactiveUI.Extensions to compare throughput and allocation behavior; the production packages must not. The latest complete BenchmarkDotNet run finished on 2026-05-29 at 06:37:05 Europe/London with .NET SDK 10.0.300 and .NET runtime 10.0.8 on Windows 11. It executed 201 benchmarks with no skipped suites in 00:21:37: @@ -686,7 +907,34 @@ Smoke validation for deterministic benchmark behavior passed for 67 benchmark gr dotnet run --project src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveUI.Primitives.Benchmarks.csproj --configuration Release --no-build -- --smoke ``` -The latest direct test/coverage validation passed 181/181 net10.0 tests and reports 91.74% line coverage and 86.01% branch coverage from `.tmp/test-results-20260529-net10-readme/coverage.cobertura.xml`. The full solution test pass also passed 558/558 tests across net8.0, net9.0, and net10.0. +The latest direct test/coverage validation passed 2032/2032 net8.0 tests and reports 99.28% line coverage and 97.22% branch coverage for `ReactiveUI.Primitives.Extensions` from `src/tests/ReactiveUI.Primitives.Tests/TestResults/extensions-optimization/coverage.cobertura.xml`. Direct executable validation also passed 2032/2032 tests on net9.0 and 2032/2032 tests on net10.0. + +Focused Async and Extensions BenchmarkDotNet runs were added on 2026-05-30 on Windows 11 with .NET SDK 10.0.300 and .NET runtime 10.0.8. They used `LaunchCount=1`, `WarmupCount=1`, and `IterationCount=3`. + +```powershell +dotnet run --project src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveUI.Primitives.Benchmarks.csproj --configuration Release --no-build -- --filter "*ReactiveExtensionsComparisonBenchmarks*" --launchCount 1 --warmupCount 1 --iterationCount 3 +dotnet run --project src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveUI.Primitives.Benchmarks.csproj --configuration Release --no-build -- --filter "*AsyncExtensionsComparisonBenchmarks*" --launchCount 1 --warmupCount 1 --iterationCount 3 +``` + +Extensions comparison against `ReactiveUI.Extensions` 4.0.0: + +| Scenario | ReactiveUI.Primitives.Extensions | ReactiveUI.Extensions 4.0.0 | +|---|---:|---:| +| `WhereSelect` range | 75.87 ns / 136 B | 2,655.97 ns / 2512 B | +| `FromArray` subscribe | 58.42 ns / 40 B | 59.15 ns / 40 B | +| `Pairwise` range | 515.51 ns / 160 B | 3,016.54 ns / 2536 B | +| `BufferUntil` chars | 52.81 ns / 264 B | 53.01 ns / 264 B | +| `Not` + `WhereTrue` | 28.81 ns / 144 B | 30.42 ns / 144 B | + +Async comparison against `ReactiveUI.Extensions.Async` 4.0.0: + +| Scenario | ReactiveUI.Primitives.Async | ReactiveUI.Extensions.Async 4.0.0 | +|---|---:|---:| +| `Sequence` + `Map` + `Keep` + `ToListAsync` | 2,109.7 ns / 1600 B | 2,085.6 ns / 1600 B | +| Async `CountAsync` | 817.2 ns / 704 B | 808.0 ns / 704 B | +| Async signal/subject broadcast | 6,822.4 ns / 2320 B | 6,802.5 ns / 2320 B | + +The focused Extensions results show the fused and direct-observer operators winning the high-value migrated scenarios while preserving allocations where both implementations use equivalent buffering. The Async results are allocation-equivalent to the baseline and within measurement noise on the selected parity paths. The table below is generated from the joined BenchmarkDotNet CSV and uses `Mean / Allocated` for each cell. @@ -806,6 +1054,8 @@ Performance constraints used by the project: | Path | Purpose | |---|---| | `src/ReactiveUI.Primitives` | Production runtime library. | +| `src/ReactiveUI.Primitives.Async` | Async observable/signal library built on `IObservableAsync` and `IObserverAsync`. | +| `src/ReactiveUI.Primitives.Extensions` | Migrated non-async ReactiveUI.Extensions helper operators backed by ReactiveUI.Primitives. | | `src/ReactiveUI.Primitives.Wpf` | Optional WPF dispatcher integration library. | | `src/ReactiveUI.Primitives.WinForms` | Optional Windows Forms control integration library. | | `src/ReactiveUI.Primitives.WinUI` | Optional WinUI dispatcher queue integration library. | @@ -822,22 +1072,23 @@ Performance constraints used by the project: ```powershell # Build solution. -dotnet build .\src\ReactiveUI.Primitives.slnx -c Release --no-restore -v:minimal -p:UseSharedCompilation=false -p:BuildInParallel=false -maxcpucount:1 +dotnet build .\src\ReactiveUI.Primitives.slnx -c Release -v minimal /nr:false -p:UseSharedCompilation=false -p:BuildInParallel=false -maxcpucount:1 -# Net10 coverage run through the Microsoft Testing Platform/TUnit executable. -dotnet .\src\tests\ReactiveUI.Primitives.Tests\bin\Release\net10.0\ReactiveUI.Primitives.Tests.dll --results-directory .\.tmp\test-results-20260529-net10-readme --report-trx --report-trx-filename net10.trx --coverage --coverage-output coverage.cobertura.xml --coverage-output-format cobertura --no-progress --maximum-parallel-tests 1 --timeout 10m --output Normal --show-stdout Failed --show-stderr Failed --disable-logo +# Net8 coverage run through the Microsoft Testing Platform/TUnit executable. +dotnet .\src\tests\ReactiveUI.Primitives.Tests\bin\Release\net8.0\ReactiveUI.Primitives.Tests.dll --coverage --coverage-output coverage.cobertura.xml --coverage-output-format cobertura --results-directory .\src\tests\ReactiveUI.Primitives.Tests\TestResults\extensions-optimization --no-ansi --no-progress --output Normal --timeout 10m -# All target-framework tests. -Push-Location .\src -dotnet test .\ReactiveUI.Primitives.slnx -c Release --no-build -v:minimal --results-directory ..\.tmp\test-results-20260529-solution-readme -- --report-trx --report-trx-filename solution.trx --no-progress --maximum-parallel-tests 1 --timeout 10m --disable-logo -Pop-Location +# Remaining test target frameworks through the generated TUnit executables. +dotnet .\src\tests\ReactiveUI.Primitives.Tests\bin\Release\net9.0\ReactiveUI.Primitives.Tests.dll --results-directory .\src\tests\ReactiveUI.Primitives.Tests\TestResults\extensions-optimization-net9 --no-ansi --no-progress --output Normal --timeout 10m +dotnet .\src\tests\ReactiveUI.Primitives.Tests\bin\Release\net10.0\ReactiveUI.Primitives.Tests.dll --results-directory .\src\tests\ReactiveUI.Primitives.Tests\TestResults\extensions-optimization-net10 --no-ansi --no-progress --output Normal --timeout 10m -# Benchmark smoke and complete joined comparison run. +# Benchmark smoke, focused Async/Extensions comparisons, and complete joined comparison run. dotnet run --project .\src\benchmarks\ReactiveUI.Primitives.Benchmarks\ReactiveUI.Primitives.Benchmarks.csproj --configuration Release --no-build -- --smoke +dotnet run --project .\src\benchmarks\ReactiveUI.Primitives.Benchmarks\ReactiveUI.Primitives.Benchmarks.csproj --configuration Release --no-build -- --filter "*ReactiveExtensionsComparisonBenchmarks*" --launchCount 1 --warmupCount 1 --iterationCount 3 +dotnet run --project .\src\benchmarks\ReactiveUI.Primitives.Benchmarks\ReactiveUI.Primitives.Benchmarks.csproj --configuration Release --no-build -- --filter "*AsyncExtensionsComparisonBenchmarks*" --launchCount 1 --warmupCount 1 --iterationCount 3 dotnet run --project .\src\benchmarks\ReactiveUI.Primitives.Benchmarks\ReactiveUI.Primitives.Benchmarks.csproj --configuration Release --no-build -- --filter "*" --join --launchCount 1 --warmupCount 1 --iterationCount 3 ``` -Results: build passed with 0 warnings/0 errors; the net10.0 coverage run passed 181/181 tests; `dotnet test` passed 558/558 tests across net8.0, net9.0, and net10.0; benchmark smoke parity passed for 67 groups; the joined BenchmarkDotNet run executed 201 benchmarks. The latest coverage snapshot reported 91.74% line (10740/11707) and 86.01% branch (4280/4976) coverage. +Results: build passed with 0 warnings/0 errors; the net8.0 coverage run passed 2032/2032 tests; direct TUnit executable validation passed 2032/2032 tests on net9.0 and 2032/2032 tests on net10.0. The latest `ReactiveUI.Primitives.Extensions` coverage snapshot reported 99.28% line (3171/3194) and 97.22% branch (875/900) coverage. Benchmark smoke parity passed for 67 groups; the joined BenchmarkDotNet run executed 201 benchmarks, and the focused Async/Extensions BenchmarkDotNet runs produced the comparison tables above. ### Package verification diff --git a/Skills.md b/Skills.md new file mode 100644 index 0000000..e11ee46 --- /dev/null +++ b/Skills.md @@ -0,0 +1,520 @@ +--- +name: reactiveui-primitives +description: Use when working with ReactiveUI.Primitives NuGet packages, migrating from System.Reactive or R3, choosing Signal/Sequencer/Async/Extensions APIs, or using the generated bridge adapters without adding runtime Rx or R3 dependencies. +--- + +# ReactiveUI.Primitives + +Use this skill when a .NET project consumes ReactiveUI.Primitives from NuGet and needs reactive streams, state, scheduling, async observables, migrated ReactiveUI.Extensions helpers, UI sequencer adapters, or migration guidance from System.Reactive or R3. + +Assume package consumption from NuGet. Do not assume repository source paths, local project references, or repository-only workflows. + +## Package Setup + +Install the packages that match the target application surface: + +```bash +dotnet add package ReactiveUI.Primitives +dotnet add package ReactiveUI.Primitives.Async +dotnet add package ReactiveUI.Primitives.Extensions +``` + +Add UI adapter packages only when the application needs that UI thread integration: + +```bash +dotnet add package ReactiveUI.Primitives.Wpf +dotnet add package ReactiveUI.Primitives.WinForms +dotnet add package ReactiveUI.Primitives.WinUI +dotnet add package ReactiveUI.Primitives.Blazor +dotnet add package ReactiveUI.Primitives.Maui +``` + +Common imports: + +```csharp +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions; +using ReactiveUI.Primitives.Signals; +``` + +For async APIs: + +```csharp +using ReactiveUI.Primitives.Async; +using ReactiveUI.Primitives.Async.Signals; +``` + +## Design Rules For Agents + +- Prefer ReactiveUI.Primitives names when writing new code: `Signal`, `Map`, `Keep`, `Spark`, `ISequencer`. +- Do not introduce a System.Reactive or R3 runtime dependency unless the user's project already has that dependency or explicitly needs a bridge boundary. +- Use bridge source-generator methods only at interop boundaries. Convert at the edge, then keep internal code in Primitives types and naming. +- Use `ISequencer` for scheduling and UI marshalling. Do not design new code around `IScheduler`. +- Use `IDisposable` and `IAsyncDisposable` lifetimes explicitly. Store subscriptions in `MultipleDisposable`, `Pocket`, `Slot`, or async disposable containers as appropriate. +- Keep async pipelines in `IObservableAsync` when the observer work is asynchronous or cancellation-aware. + +## Core Package + +`ReactiveUI.Primitives` is the base package. It uses BCL `IObservable` and `IObserver` contracts and has no runtime System.Reactive or R3 dependency. + +Important public types: + +- `Signal`: hot subject-like source and sink. Use instead of `Subject`. +- `BehaviorSignal`: signal with current value replayed to new subscribers. +- `StateSignal`: mutable current state signal with a `Value` setter and state projection helpers. +- `ReadOnlyState` and `ProjectedReadOnlyState`: read-only state projections. +- `ReplaySignal` and `HistorySignal`: replay buffered values by count and optional time window. +- `ConnectableSignal`: explicit connectable source for share, replay, auto-connect, and ref-count flows. +- `TaskSignal` and `TaskSignal`: task-backed signal sources with cancellation-aware execution. +- `CommandSignal` and `CommandExecution`: command execution, `CanRun`, `IsRunning`, results, faults, and execution lifecycle. +- `RxVoid`: no-value event marker, equivalent to Rx `Unit`. +- `Spark` and `SparkKind`: materialized next/error/completed notifications. +- `EventPattern`, `Moment`, and `TimeInterval`: event and time wrappers. + +Signal factory entry point: + +```csharp +using ReactiveUI.Primitives.Signals; + +IObservable values = Signal.Sequence(1, 3); +IObservable one = Signal.Emit("ready"); +IObservable none = Signal.None(); +IObservable failed = Signal.Fail(new InvalidOperationException("boom")); +IObservable tick = Signal.Every(TimeSpan.FromSeconds(1)); +``` + +Core factories: + +- `Signal.Create`, `CreateSafe`, `CreateWithState`, `Lazy` +- `Emit`, `EmitRxVoid`, `None`, `Silent`, `Fail` +- `Sequence`, `Loop`, `Unfold`, `Iterate` +- `FromEnumerable`, `FromAsyncEnumerable`, `FromTask`, `FromAsync`, `FromEventPattern` +- `Start`, `Use`, `After`, `Every`, `Pulse` +- `Chain`, `Blend`, `Race`, `Pair`, `SyncLatest`, `PairLatest`, `ForkJoin` + +Example: + +```csharp +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Signals; + +var input = new Signal(); + +using var subscription = input + .Keep(value => value > 0) + .Map(value => value * 2) + .Subscribe(value => Console.WriteLine(value)); + +input.OnNext(21); +input.OnCompleted(); +``` + +## Operator Vocabulary + +ReactiveUI.Primitives keeps common LINQ-compatible names where useful and adds distinct names to avoid confusion with System.Reactive and R3. + +Prefer these Primitives names in new code: + +| System.Reactive or R3 concept | ReactiveUI.Primitives name | +| --- | --- | +| `Subject` | `Signal` | +| `BehaviorSubject` | `BehaviorSignal` or `StateSignal` | +| `ReplaySubject` | `ReplaySignal` or `HistorySignal` | +| `Observable.Return` | `Signal.Emit` | +| `Observable.Empty` | `Signal.None` | +| `Observable.Never` | `Signal.Silent` | +| `Observable.Throw` | `Signal.Fail` | +| `Observable.Range` | `Signal.Sequence` | +| `Observable.Timer` | `Signal.After` | +| `Observable.Interval` | `Signal.Every` or `Signal.Pulse` | +| `Select` | `Map` | +| `Where` | `Keep` | +| `OfType` | `KeepType` | +| `Cast` | `CastTo` | +| `Where(x is not null)` | `KeepNotNull` | +| `Do` | `Tap` or `TapWith` | +| `Scan` | `Fold` | +| `Aggregate` | `Reduce` | +| `SelectMany` | `FlatMap` or `Bind` | +| `Concat` | `Chain` | +| `Merge` | `Blend` | +| `Amb` | `Race` | +| `Zip` | `Pair` | +| `CombineLatest` | `SyncLatest` or `PairLatest` | +| `WithLatestFrom` | `Latch` | +| `Switch` | `SwitchTo` | +| `Retry` | `Reattempt` | +| `Catch` | `Recover`, `Rescue`, or `Resume` | +| `Delay` | `Shift` | +| `Timeout` | `Expire` | +| `StartWith` | `Lead` | +| `Materialize` | `Spark` | +| `Dematerialize` | `Unspark` | +| `Unit` | `RxVoid` | +| `Notification` | `Spark` | +| `IScheduler` | `ISequencer` | +| `CompositeDisposable` | `MultipleDisposable`, `Pocket`, or the `CompositeDisposable` alias | +| `SerialDisposable` | `SingleReplaceableDisposable` or `Slot` | +| `SingleAssignmentDisposable` | `SingleDisposable` or `AssignmentSlot` | + +Standard names such as `Take`, `Skip`, `Distinct`, `DistinctBy`, `DefaultIfEmpty`, `Buffer`, `Timestamp`, `TimeInterval`, `SubscribeOn`, `ObserveOn`, terminal collection methods, and async terminal methods may also be available. Prefer the Primitives name when both exist and the target code is intended to avoid Rx/R3 vocabulary. + +## Sequencers + +`ISequencer` is the scheduling abstraction. Use it instead of Rx `IScheduler`. + +Common sequencers: + +- `Sequencer.Immediate` +- `Sequencer.CurrentThread` +- `Sequencer.Default` +- `ImmediateSequencer` +- `CurrentThreadSequencer` +- `TaskPoolSequencer` +- `ThreadPoolSequencer` +- `SynchronizationContextSequencer` +- `VirtualClock`, `TestClock`, and `VirtualTimeSequencer` for deterministic virtual time + +Example: + +```csharp +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Signals; + +ISequencer ui = SynchronizationContextSequencer.Current; + +using var subscription = Signal + .Every(TimeSpan.FromSeconds(1), Sequencer.Default) + .ObserveOn(ui) + .Subscribe(_ => RefreshView()); +``` + +UI adapter packages provide UI-specific sequencers: + +- WPF: `DispatcherSequencer` +- WinForms: `ControlSequencer` +- WinUI: `DispatcherQueueSequencer` and `ToSequencer` +- Blazor: `BlazorRendererSequencer` and `ReactiveComponentBase` +- MAUI: `MauiDispatcherSequencer` and `ToSequencer` + +## Disposables + +Use `ReactiveUI.Primitives.Disposables` for subscription and resource lifetimes: + +- `Disposable.Empty` and `Disposable.Create(Action)` +- `BooleanDisposable` +- `CancellationDisposable` +- `MultipleDisposable` and `CompositeDisposable` +- `Pocket` +- `SingleDisposable` and `AssignmentSlot` +- `SingleReplaceableDisposable` and `Slot` +- `MutableDisposable`, `SwapDisposable`, and `DisposableBag` +- `Handle`, `Handle`, `Handle`, `Handle` + +Example: + +```csharp +using ReactiveUI.Primitives.Disposables; + +var subscriptions = new MultipleDisposable(); + +source.Subscribe(value => Console.WriteLine(value)) + .DisposeWith(subscriptions); + +subscriptions.Dispose(); +``` + +## Async Package + +`ReactiveUI.Primitives.Async` adds asynchronous observable contracts: + +- `IObservableAsync` +- `IObserverAsync` +- `ObserverAsync` +- `SignalAsync` +- `ISignalAsync` +- `ConnectableSignalAsync` +- `Result` +- `Optional` +- `AsyncContext` + +Async observer methods return `ValueTask` and accept cancellation where relevant: + +- `OnNextAsync(T value, CancellationToken cancellationToken)` +- `OnErrorResumeAsync(Exception error, CancellationToken cancellationToken)` +- `OnCompletedAsync(Result result)` +- `DisposeAsync()` + +Use `ReactiveUI.Primitives.Async.SignalAsync` for async observable factories and operators: + +```csharp +using ReactiveUI.Primitives.Async; + +IObservableAsync source = SignalAsync.Sequence(1, 3); + +List values = await source + .Keep(value => value > 1) + .Map(value => value * 10) + .CollectListAsync(); +``` + +Use `ReactiveUI.Primitives.Async.Signals.Signal` for mutable async signals: + +```csharp +using AsyncSignal = ReactiveUI.Primitives.Async.Signals.Signal; + +await using var signal = AsyncSignal.Create(); + +await signal.SubscribeAsync(async (value, cancellationToken) => +{ + await StoreAsync(value, cancellationToken); +}); + +await signal.OnNextAsync(42, CancellationToken.None); +await signal.OnCompletedAsync(Result.Success); +``` + +Async creation options: + +- `SignalCreationOptions` +- `BehaviorSignalCreationOptions` +- `ReplayLatestSignalCreationOptions` +- `PublishingOption.Serial` +- `PublishingOption.Concurrent` + +Async mutable signal factories: + +- `Signal.Create()` +- `Signal.Create(SignalCreationOptions?)` +- `Signal.CreateBehavior(T startValue)` +- `Signal.CreateBehavior(T startValue, BehaviorSignalCreationOptions?)` +- `Signal.CreateReplayLatest()` +- `Signal.CreateReplayLatest(ReplayLatestSignalCreationOptions?)` + +Async operator vocabulary follows the core Primitives names where possible: + +- `Emit`, `None`, `Fail`, `Sequence`, `FromEnumerable`, `FromAsyncEnumerable`, `After`, `Every`, `Pulse`, `Use` +- `Map`, `MapWith` +- `Keep`, `KeepWith`, `KeepNotNull`, `KeepType` +- `CastTo` +- `Tap` +- `Fold`, `ReduceAsync` +- `FlatMap`, `Bind` +- `Unique`, `UniqueBy` +- `Chain`, `Blend`, `SwitchTo` +- `Pair`, `SyncLatest`, `PairLatest` +- `Reattempt`, `Recover`, `Rescue`, `Resume` +- `Shift`, `Expire`, `Lead` +- `CollectListAsync`, `CollectArrayAsync` + +Async also exposes parity operators such as `AggregateAsync`, `AnyAsync`, `AllAsync`, `CountAsync`, `LongCountAsync`, `ContainsAsync`, `FirstAsync`, `FirstOrDefaultAsync`, `LastAsync`, `LastOrDefaultAsync`, `SingleAsync`, `SingleOrDefaultAsync`, `ForEachAsync`, `WaitCompletionAsync`, `ToAsyncEnumerable`, `ToDictionaryAsync`, `GroupBy`, `Merge`, `Concat`, `Zip`, `Switch`, `Retry`, `Throttle`, `Timeout`, `Delay`, `ObserveOn`, and `SubscribeAsync`. + +Async disposable helpers: + +- `DisposableAsync.Empty` +- `DisposableAsync.Create(...)` +- `MultipleDisposableAsync` +- `SingleAssignmentDisposableAsync` +- `SingleReplaceableDisposableAsync` +- `DisposableAsyncSlot` + +## Extensions Package + +`ReactiveUI.Primitives.Extensions` contains migrated convenience operators that reference ReactiveUI.Primitives and avoid production System.Reactive/R3 dependencies. + +High-value extension areas: + +- Null and boolean helpers: `WhereIsNotNull`, `SkipWhileNull`, `WhereTrue`, `WhereFalse`, `Not` +- Signal helpers: `AsSignal`, `ToReadOnlyBehavior`, `ReplayLastOnSubscribe` +- Error helpers: `CatchIgnore`, `CatchAndReturn`, `CatchReturn`, `CatchReturnUnit`, `LogErrors` +- Retry helpers: `OnErrorRetry`, `RetryWithBackoff`, `RetryWithDelay`, `RetryForeverWithDelay`, `RetryWithFixedDelay` +- Timing helpers: `SyncTimer`, `Schedule`, `SampleLatest`, `ThrottleFirst`, `ThrottleUntilTrue`, `ThrottleOnScheduler`, `ThrottleDistinct`, `DebounceImmediate`, `DebounceUntil` +- Buffering and state helpers: `BufferUntil`, `BufferUntilIdle`, `BufferUntilInactive`, `Conflate`, `Heartbeat`, `DetectStale`, `LatestOrDefault` +- Async interop helpers: `SubscribeAsync`, `SelectAsync`, `SelectAsyncSequential`, `SelectLatestAsync`, `SelectAsyncConcurrent`, `ToHotTask`, `ToHotValueTask`, `WaitUntil`, `DropIfBusy` +- Collection and projection helpers: `ForEach`, `FromArray`, `Using`, `While`, `ScanWithInitial`, `Filter`, `Shuffle`, `Pairwise`, `Partition`, `WhereSelect`, `SelectConstant`, `TrySelect`, `SelectManyThen`, `RunAll`, `FirstMatchFromCandidates` +- Subscription helpers: `SubscribeGetValue`, `SubscribeGetError`, `WaitForValue`, `WaitForCompletion`, `WaitForError` + +Example: + +```csharp +using ReactiveUI.Primitives.Extensions; + +IObservable nonEmpty = names + .WhereIsNotNull() + .Keep(name => name.Length > 0) + .ReplayLastOnSubscribe(); +``` + +## Source Generator Bridges + +The base package packs source generators as analyzers. The async package also packs them so async bridge methods can be generated. + +The generators do not add runtime System.Reactive or R3 dependencies to ReactiveUI.Primitives. They emit bridge code only when the consuming project already references the relevant external package. + +System.Reactive bridge: + +- Generated namespace: `ReactiveUI.Primitives.SystemReactiveBridge` +- Available when `System.Reactive` symbols are present. +- `AsPrimitivesSignal(this System.IObservable)` +- `AsSystemObservable(this System.IObservable)` +- `AsSequencer(this System.Reactive.Concurrency.IScheduler)` +- `AsSystemScheduler(this ReactiveUI.Primitives.Concurrency.ISequencer)` +- If `ReactiveUI.Primitives.Async` is referenced: `ToObservableAsync(this IObservable)` +- If `ReactiveUI.Primitives.Async` is referenced: `ToObservable(this IObservableAsync)` + +R3 bridge: + +- Generated namespace: `ReactiveUI.Primitives.R3Bridge` +- Available when `R3.Observable` symbols are present. +- `AsPrimitivesSignal(this R3.Observable)` +- `AsR3Observable(this System.IObservable)` +- If `ReactiveUI.Primitives.Async` is referenced: `AsPrimitivesAsyncObservable(this R3.Observable)` +- If `ReactiveUI.Primitives.Async` is referenced: `AsR3Observable(this IObservableAsync)` + +Bridge example for System.Reactive: + +```csharp +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.SystemReactiveBridge; + +IObservable primitives = rxObservable.AsPrimitivesSignal(); + +using var subscription = primitives + .Map(value => value + 1) + .Subscribe(Console.WriteLine); +``` + +Bridge example for R3: + +```csharp +using ReactiveUI.Primitives.R3Bridge; + +IObservable primitives = r3Observable.AsPrimitivesSignal(); +R3.Observable r3 = primitives.AsR3Observable(); +``` + +Generated bridge classes are internal to the consuming assembly. The extension methods are still usable by code in that assembly after importing the generated namespace. + +## System.Reactive Migration + +Use this migration path: + +1. Add `ReactiveUI.Primitives`. +2. Add `ReactiveUI.Primitives.Async`, `ReactiveUI.Primitives.Extensions`, or UI adapter packages only where needed. +3. Keep System.Reactive package references temporarily only in assemblies that still consume Rx APIs or need bridge adapters. +4. Convert hot sources first: `Subject` to `Signal`, `BehaviorSubject` to `StateSignal` or `BehaviorSignal`, and `ReplaySubject` to `ReplaySignal` or `HistorySignal`. +5. Replace operators with Primitives vocabulary at the same time as touching code. +6. Replace `IScheduler` dependencies with `ISequencer` dependencies. Use generated scheduler bridge adapters only at external Rx boundaries. +7. Replace Rx disposables with Primitives disposables. +8. Remove System.Reactive package references from assemblies once no Rx symbols remain. + +System.Reactive factory mapping: + +```csharp +// Before +var values = System.Reactive.Linq.Observable.Range(1, 3); + +// After +var values = ReactiveUI.Primitives.Signals.Signal.Sequence(1, 3); +``` + +System.Reactive subject mapping: + +```csharp +// Before +var subject = new System.Reactive.Subjects.Subject(); + +// After +var signal = new ReactiveUI.Primitives.Signals.Signal(); +``` + +State mapping: + +```csharp +// Before +var current = new System.Reactive.Subjects.BehaviorSubject(0); + +// After +var state = new ReactiveUI.Primitives.Signals.StateSignal(0); +state.Value = 1; +``` + +Command migration: + +```csharp +using ReactiveUI.Primitives.Signals; + +var canRun = new StateSignal(true); +var command = new CommandSignal( + async cancellationToken => + { + await SaveAsync(cancellationToken); + return "saved"; + }, + canRun); + +using var results = command.Results.Subscribe(Console.WriteLine); +await command.ExecuteAsync(CancellationToken.None); +``` + +## R3 Migration + +Use this migration path: + +1. Add `ReactiveUI.Primitives`. +2. Keep R3 references temporarily only in assemblies that still expose or consume R3 APIs. +3. Convert R3 hot sources to `Signal` or async signals depending on whether observer work is synchronous or asynchronous. +4. Convert `R3.Observable` to BCL `IObservable` with `AsPrimitivesSignal()` at the boundary when the generated bridge is available. +5. Convert Primitives streams back to R3 only at public boundaries that must still expose R3. +6. Prefer `ISequencer` and Primitives time operators for new scheduling code. +7. Remove R3 references from assemblies once no R3 symbols remain. + +R3 bridge example: + +```csharp +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.R3Bridge; + +IObservable primitives = r3Source.AsPrimitivesSignal(); + +using var subscription = primitives + .Keep(value => value > 0) + .Map(value => value * 2) + .Subscribe(Console.WriteLine); +``` + +Async R3 bridge example: + +```csharp +using ReactiveUI.Primitives.Async; +using ReactiveUI.Primitives.R3Bridge; + +IObservableAsync asyncSource = r3Source.AsPrimitivesAsyncObservable(); +List values = await asyncSource.CollectListAsync(); +``` + +## Choosing Sync Or Async + +Use `IObservable` and the base package when: + +- Values are pushed synchronously. +- Observer work is fast and does not need `await`. +- Existing consumers already use BCL `IObservable`. + +Use `IObservableAsync` and the async package when: + +- Observers perform asynchronous work. +- Backpressure-like awaiting between observer calls matters. +- Cancellation needs to flow through subscriptions and `OnNextAsync`. +- Completion needs the async `Result` shape, especially when bridging from R3. + +## Common Mistakes To Avoid + +- Do not rename `Signal` back to `Subject` in Primitives code. +- Do not introduce `System.Reactive.Linq.Observable` just for basic factories; use `ReactiveUI.Primitives.Signals.Signal`. +- Do not expose `IScheduler` in new APIs; expose `ISequencer`. +- Do not mix `SignalAsync` and `Signal` in the same pipeline without an explicit bridge or conversion reason. +- Do not keep bridge conversions in the middle of a pipeline. Convert once at the boundary. +- Do not use UI sequencers from the base package; install the matching UI adapter package. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index e45d640..9d9e393 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -50,9 +50,9 @@ net8.0;net9.0;net10.0 - net462;net472;net481 + net462;net472;net48;net481 - + $(NetTargetFrameworks);$(NetFrameworkTargetFrameworks) diff --git a/src/ReactiveUI.Primitives.Async/Disposables/MultipleDisposableAsync.cs b/src/ReactiveUI.Primitives.Async/Disposables/MultipleDisposableAsync.cs index 5443074..198fd72 100644 --- a/src/ReactiveUI.Primitives.Async/Disposables/MultipleDisposableAsync.cs +++ b/src/ReactiveUI.Primitives.Async/Disposables/MultipleDisposableAsync.cs @@ -29,11 +29,7 @@ public sealed class MultipleDisposableAsync : IAsyncDisposable /// /// The synchronization gate protecting all mutable state in this collection. /// -#if NET9_0_OR_GREATER private readonly Lock _gate = new(); -#else - private readonly object _gate = new(); -#endif /// /// Backing array of disposables. Slots may be after removal to avoid shifting elements; diff --git a/src/ReactiveUI.Primitives.Async/Operators/CombineLatestEnumerable.cs b/src/ReactiveUI.Primitives.Async/Operators/CombineLatestEnumerable.cs index dc6bde6..a8251f7 100644 --- a/src/ReactiveUI.Primitives.Async/Operators/CombineLatestEnumerable.cs +++ b/src/ReactiveUI.Primitives.Async/Operators/CombineLatestEnumerable.cs @@ -138,13 +138,8 @@ internal sealed class Subscription( /// Cancellation source for disposal. private readonly CancellationTokenSource _disposeCts = new(); -#if NET9_0_OR_GREATER /// The completion lock. private readonly Lock _completionLock = new(); -#else - /// The completion lock. - private readonly object _completionLock = new(); -#endif /// Downstream observer. [SuppressMessage( diff --git a/src/ReactiveUI.Primitives.Async/Operators/ParityHelpers.OperatorFusions.cs b/src/ReactiveUI.Primitives.Async/Operators/ParityHelpers.OperatorFusions.cs index 9af0f29..1c7fd24 100644 --- a/src/ReactiveUI.Primitives.Async/Operators/ParityHelpers.OperatorFusions.cs +++ b/src/ReactiveUI.Primitives.Async/Operators/ParityHelpers.OperatorFusions.cs @@ -208,11 +208,7 @@ internal sealed class ThrottleDistinctObserver( private static readonly EqualityComparer Comparer = EqualityComparer.Default; /// Synchronization gate protecting throttle/distinct state. -#if NET9_0_OR_GREATER private readonly Lock _gate = new(); -#else - private readonly object _gate = new(); -#endif /// Most-recent upstream value (for upstream DistinctUntilChanged). private T _lastUpstream = default!; @@ -488,11 +484,7 @@ public PartitionCoordinator(IObservableAsync source, Func predicate) } /// Synchronization gate protecting branch slots and the source-subscription lifecycle. -#if NET9_0_OR_GREATER private readonly Lock _gate = new(); -#else - private readonly object _gate = new(); -#endif /// The active observer for the truthy branch, or when nobody is subscribed. private IObserverAsync? _trueObserver; @@ -777,11 +769,7 @@ internal sealed class DebounceUntilObserver( CancellationToken subscribeToken) : ObserverAsync(subscribeToken) { /// Synchronization gate protecting the id counter. -#if NET9_0_OR_GREATER private readonly Lock _gate = new(); -#else - private readonly object _gate = new(); -#endif /// Monotonically increasing identifier used to detect supersession of pending delays. private long _id; diff --git a/src/ReactiveUI.Primitives.Async/Operators/SwitchSignal.cs b/src/ReactiveUI.Primitives.Async/Operators/SwitchSignal.cs index 7b13fcd..8cc0119 100644 --- a/src/ReactiveUI.Primitives.Async/Operators/SwitchSignal.cs +++ b/src/ReactiveUI.Primitives.Async/Operators/SwitchSignal.cs @@ -59,17 +59,10 @@ internal sealed class SwitchSubscription : IAsyncDisposable /// private readonly CancellationToken _disposeCancellationToken; -#if NET9_0_OR_GREATER /// /// Lock that protects mutable state from concurrent access. /// private readonly Lock _gate = new(); -#else - /// - /// Lock that protects mutable state from concurrent access. - /// - private readonly object _gate = new(); -#endif /// /// Async gate that serializes observer callbacks to ensure thread-safe emission. diff --git a/src/ReactiveUI.Primitives.Async/Operators/Throttle.cs b/src/ReactiveUI.Primitives.Async/Operators/Throttle.cs index ba7a3cd..882c016 100644 --- a/src/ReactiveUI.Primitives.Async/Operators/Throttle.cs +++ b/src/ReactiveUI.Primitives.Async/Operators/Throttle.cs @@ -121,11 +121,7 @@ internal sealed class ThrottleObserver(IObserverAsync observer, TimeSpan dueT /// /// The synchronization gate protecting shared throttle state. /// -#if NET9_0_OR_GREATER private readonly Lock _gate = new(); -#else - private readonly object _gate = new(); -#endif /// /// A monotonically increasing identifier used to detect whether a newer element has superseded the current timer. diff --git a/src/ReactiveUI.Primitives.Async/Operators/Timeout.cs b/src/ReactiveUI.Primitives.Async/Operators/Timeout.cs index add065c..8ffbf84 100644 --- a/src/ReactiveUI.Primitives.Async/Operators/Timeout.cs +++ b/src/ReactiveUI.Primitives.Async/Operators/Timeout.cs @@ -141,11 +141,7 @@ internal sealed class TimeoutObserver(IObserverAsync observer, TimeSpan dueTi /// /// Synchronization gate protecting timer state. /// -#if NET9_0_OR_GREATER private readonly Lock _gate = new(); -#else - private readonly object _gate = new(); -#endif /// /// Single pre-allocated timer rearmed via diff --git a/src/ReactiveUI.Primitives.Async/Operators/Zip.cs b/src/ReactiveUI.Primitives.Async/Operators/Zip.cs index 68d09fa..f2068d6 100644 --- a/src/ReactiveUI.Primitives.Async/Operators/Zip.cs +++ b/src/ReactiveUI.Primitives.Async/Operators/Zip.cs @@ -111,11 +111,7 @@ internal sealed class ZipState( /// /// The synchronization gate protecting shared state access. /// -#if NET9_0_OR_GREATER private readonly Lock _gate = new(); -#else - private readonly object _gate = new(); -#endif /// /// Queue of buffered elements from the first source awaiting a pair from the second source. diff --git a/src/ReactiveUI.Primitives.Async/ReactiveUI.Primitives.Async.csproj b/src/ReactiveUI.Primitives.Async/ReactiveUI.Primitives.Async.csproj index 2a395df..c6de0b0 100644 --- a/src/ReactiveUI.Primitives.Async/ReactiveUI.Primitives.Async.csproj +++ b/src/ReactiveUI.Primitives.Async/ReactiveUI.Primitives.Async.csproj @@ -22,34 +22,24 @@ all runtime; build; native; contentfiles; analyzers - - - - - - - + + + + + + + - - + + - - + + diff --git a/src/ReactiveUI.Primitives.Async/Signals/Base/BaseSignalAsync.cs b/src/ReactiveUI.Primitives.Async/Signals/Base/BaseSignalAsync.cs index 9ad1043..be0f1a5 100644 --- a/src/ReactiveUI.Primitives.Async/Signals/Base/BaseSignalAsync.cs +++ b/src/ReactiveUI.Primitives.Async/Signals/Base/BaseSignalAsync.cs @@ -23,11 +23,7 @@ public abstract class BaseSignalAsync : SignalAsync, ISignalAsync /// /// The lock object used to synchronize access to the Signal's mutable state. /// -#if NET9_0_OR_GREATER private readonly Lock _gate = new(); -#else - private readonly object _gate = new(); -#endif /// /// The immutable list of currently subscribed observers. diff --git a/src/ReactiveUI.Primitives.Extensions/Continuation.cs b/src/ReactiveUI.Primitives.Extensions/Continuation.cs new file mode 100644 index 0000000..d559e42 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Continuation.cs @@ -0,0 +1,137 @@ +// 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.Primitives.Extensions; + +/// +/// Continuation. +/// +public class Continuation : IDisposable +{ + /// + /// The barrier used to synchronize phases between the lock holder and the continuation. + /// + private readonly Barrier _phaseSync = new(2); + + /// + /// A value indicating whether this instance has been disposed. + /// + private bool _disposedValue; + + /// + /// A value indicating whether the continuation is currently locked. + /// + private bool _locked; + + /// + /// Gets the number of completed phases. + /// + /// + /// The completed phases. + /// + public long CompletedPhases => _phaseSync.CurrentPhaseNumber; + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Locks this instance. + /// + /// The type of the elements in the source sequence. + /// The item. + /// The observer. + /// + /// A representing the asynchronous operation. + /// + public Task Lock(T item, IObserver<(T value, IDisposable Sync)>? observer) + { + if (_locked) + { + return Task.CompletedTask; + } + + _locked = true; + observer?.OnNext((item, this)); + return ScheduleSignalPhase(); + } + + /// + /// -returning counterpart to . Use this at per-emission + /// call sites where the returned task is awaited exactly once — saves the boxed + /// wrapper allocation in the already-locked fast path. + /// + /// The type of the elements in the source sequence. + /// The item. + /// The observer. + /// A representing the asynchronous operation. + public ValueTask LockValueTask(T item, IObserver<(T value, IDisposable Sync)>? observer) + { + if (_locked) + { + return default; + } + + _locked = true; + observer?.OnNext((item, this)); + return new ValueTask(ScheduleSignalPhase()); + } + + /// + /// UnLocks this instance. + /// + /// A representing the asynchronous operation. + internal Task UnLock() + { + if (!_locked) + { + return Task.CompletedTask; + } + + _locked = false; + return ScheduleSignalPhase(); + } + + /// + /// Releases unmanaged and - optionally - managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected virtual async void Dispose(bool disposing) + { + if (_disposedValue) + { + return; + } + + if (disposing) + { + await UnLock().ConfigureAwait(false); + _phaseSync.Dispose(); + } + + _disposedValue = true; + } + + /// Static state-carrying signal callback; avoids the per-call closure allocation a captured lambda would produce. + /// The owning instance. + private static void SignalPhaseSync(object? state) => ((Continuation)state!)._phaseSync.SignalAndWait(CancellationToken.None); + + /// Schedules on the default task scheduler. Hoisted + /// out of the and call sites because cobertura + /// tags the multi-argument Task.Factory.StartNew(...) call as a branch line — the + /// per-call overload-resolution metadata is collapsed here so it counts once. + /// The task representing the scheduled signal work. + private Task ScheduleSignalPhase() => + Task.Factory.StartNew( + SignalPhaseSync, + this, + CancellationToken.None, + TaskCreationOptions.DenyChildAttach, + TaskScheduler.Default); +} diff --git a/src/ReactiveUI.Primitives.Extensions/Heartbeat.cs b/src/ReactiveUI.Primitives.Extensions/Heartbeat.cs new file mode 100644 index 0000000..27c7e5d --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Heartbeat.cs @@ -0,0 +1,40 @@ +// 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.Primitives.Extensions; + +/// +/// Represents either a heartbeat signal or a value update from an observable stream. Value-type shape; the +/// heartbeat operator emits these directly so per-emission allocations are zero. Note that +/// default(Heartbeat<T>) represents a value update with the default ; use +/// new Heartbeat<T>() to construct a heartbeat tick. +/// +/// The type of the update value. +public readonly record struct Heartbeat : IHeartbeat +{ + /// + /// Initializes a new instance of the struct representing a heartbeat tick. + /// + public Heartbeat() + { + IsHeartbeat = true; + Update = default; + } + + /// + /// Initializes a new instance of the struct representing a value update. + /// + /// The update value. + public Heartbeat(T? update) + { + IsHeartbeat = false; + Update = update; + } + + /// + public bool IsHeartbeat { get; } + + /// + public T? Update { get; } +} diff --git a/src/ReactiveUI.Primitives.Extensions/IHeartbeat.cs b/src/ReactiveUI.Primitives.Extensions/IHeartbeat.cs new file mode 100644 index 0000000..a68db00 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/IHeartbeat.cs @@ -0,0 +1,24 @@ +// 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.Primitives.Extensions; + +/// +/// Heart beat. +/// +/// The type. +public interface IHeartbeat +{ + /// + /// Gets a value indicating whether this instance is heartbeat. + /// + /// true if this instance is heartbeat; otherwise, false. + bool IsHeartbeat { get; } + + /// + /// Gets the update. + /// + /// The update. + T? Update { get; } +} diff --git a/src/ReactiveUI.Primitives.Extensions/IStale.cs b/src/ReactiveUI.Primitives.Extensions/IStale.cs new file mode 100644 index 0000000..976fe27 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/IStale.cs @@ -0,0 +1,24 @@ +// 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.Primitives.Extensions; + +/// +/// Indicator for connection that has become stale. +/// +/// The type. +public interface IStale +{ + /// + /// Gets a value indicating whether this instance is stale. + /// + /// true if this instance is stale; otherwise, false. + bool IsStale { get; } + + /// + /// Gets the update. + /// + /// The update. + T? Update { get; } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Internal/ArgumentExceptionHelper.cs b/src/ReactiveUI.Primitives.Extensions/Internal/ArgumentExceptionHelper.cs new file mode 100644 index 0000000..3fd0b91 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Internal/ArgumentExceptionHelper.cs @@ -0,0 +1,107 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace ReactiveUI.Primitives.Extensions.Internal; + +/// +/// Provides helper methods for argument validation. +/// These methods serve as polyfills for ArgumentExceptionHelper.ThrowIfNull and related methods +/// that are only available in newer .NET versions. +/// +[ExcludeFromCodeCoverage] +internal static class ArgumentExceptionHelper +{ + /// + /// Throws an if is null. + /// + /// The reference type argument to validate as non-null. + /// The name of the parameter with which corresponds. + public static void ThrowIfNull( + [NotNull] object? argument, + [CallerArgumentExpression(nameof(argument))] + string? paramName = null) + { + if (argument is not null) + { + return; + } + + throw new ArgumentNullException(paramName); + } + + /// + /// Validates an argument and returns it if it is not null, otherwise throws an . + /// Designed for use in constructor initializers. + /// + /// The type of the argument. + /// The argument to validate. + /// Captured automatically via . + /// The non-null argument. + public static T Check( + [NotNull] T? argument, + [CallerArgumentExpression(nameof(argument))] + string? paramName = null) + where T : class + { + if (argument is not null) + { + return argument; + } + + throw new ArgumentNullException(paramName); + } + + /// + /// Validates a string argument and returns it if it is not null or empty, otherwise throws an or . + /// Designed for use in constructor initializers. + /// + /// The argument to validate. + /// Captured automatically via . + /// The non-null, non-empty argument. + public static string Check( + [NotNull] string? argument, + [CallerArgumentExpression(nameof(argument))] + string? paramName = null) + { + if (argument is null) + { + throw new ArgumentNullException(paramName); + } + + if (argument.Length == 0) + { + throw new ArgumentException("The value cannot be an empty string.", paramName); + } + + return argument; + } + + /// + /// Throws an exception if is null or empty. + /// + /// The string argument to validate as non-null and non-empty. + /// The name of the parameter with which corresponds. + /// is null. + /// is empty. + public static void ThrowIfNullOrEmpty( + [NotNull] string? argument, + [CallerArgumentExpression(nameof(argument))] + string? paramName = null) + { + if (argument is null) + { + throw new ArgumentNullException(paramName); + } + + if (argument.Length != 0) + { + return; + } + + throw new ArgumentException("The value cannot be an empty string.", paramName); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Internal/ConcurrencyLimiter.cs b/src/ReactiveUI.Primitives.Extensions/Internal/ConcurrencyLimiter.cs new file mode 100644 index 0000000..7d9bb12 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Internal/ConcurrencyLimiter.cs @@ -0,0 +1,192 @@ +// 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.Primitives.Extensions.Internal; + +/// +/// Limits the concurrency of task execution and emits results through an observable sequence. +/// Implements directly so the surface needs no +/// ActionDisposable closure wrappers; the per-task continuation state is the +/// per-subscription instance, which is already a reference type +/// and therefore needs no boxing through . +/// +/// The type of the task results. +internal sealed class ConcurrencyLimiter : IObservable +{ + /// The synchronization gate protecting task scheduling and completion state. + private readonly Lock _gate = new(); + + /// Source enumerable; the enumerator is pulled lazily on first . + private readonly IEnumerable> _taskFunctions; + + /// Maximum concurrent task continuations. + private readonly int _maxConcurrency; + + /// The number of tasks currently in flight that have not yet completed. + private int _outstanding; + + /// Global disposal latch set by any . Preserves the + /// existing single-subscription-at-a-time semantics: once any consumer disposes, the limiter + /// stops pulling further tasks. + private int _disposed; + + /// Lazy enumerator over the source task sequence; once exhausted. + private IEnumerator>? _rator; + + /// Initializes a new instance of the class. + /// The task functions to drain. + /// The maximum concurrency. + public ConcurrencyLimiter(IEnumerable> taskFunctions, int maxConcurrency) + { + _taskFunctions = taskFunctions; + _maxConcurrency = maxConcurrency; + } + + /// Gets the observable sequence — the limiter is its own . + public IObservable Observable => this; + + /// Gets or sets a value indicating whether the limiter has been disposed by any + /// consumer. Exposed to internal tests; production paths set it via + /// . + internal bool Disposed + { + get => Volatile.Read(ref _disposed) != 0; + set => Interlocked.Exchange(ref _disposed, value ? 1 : 0); + } + + /// + public IDisposable Subscribe(IObserver observer) + { + var subscription = new Subscription(this, observer); + lock (_gate) + { + _rator ??= _taskFunctions.GetEnumerator(); + } + + for (var i = 0; i < _maxConcurrency; i++) + { + PullNextTask(subscription); + } + + return subscription; + } + + /// Clears the lazy enumerator. Caller must hold on the production + /// paths; exposed to internal tests that exercise the idempotent-second-call branch. + internal void ClearRator() + { + _rator?.Dispose(); + _rator = null; + } + + /// Test entry point that adapts a raw into a fresh + /// and pulls the next task. Production paths go through + /// which creates the subscription once. + /// The observer that will receive notifications. + internal void PullNextTask(IObserver observer) => + PullNextTask(new Subscription(this, observer)); + + /// Processes the completion of a previously-scheduled task. + /// The owning subscription. + /// The completed task. + [SuppressMessage( + "Major Bug", + "S4462:Calls to async methods should not be blocking", + Justification = "Task is guaranteed complete at this call site (IsFaulted/IsCanceled were both false above); reading .Result drives the synchronous IObserver contract without blocking.")] + private void ProcessTaskCompletion(Subscription subscription, Task completed) + { + lock (_gate) + { + if (subscription.Disposed || completed.IsFaulted || completed.IsCanceled) + { + ClearRator(); + if (!subscription.Disposed) + { + subscription.Observer.OnError((completed.Exception == null + ? new OperationCanceledException() + : completed.Exception.InnerException)!); + } + + return; + } + + subscription.Observer.OnNext(completed.Result); + if (--_outstanding == 0 && _rator == null) + { + subscription.Observer.OnCompleted(); + } + else + { + PullNextTask(subscription); + } + } + } + + /// Pulls the next task and schedules its continuation against this limiter. + /// The owning subscription. + private void PullNextTask(Subscription subscription) + { + lock (_gate) + { + if (subscription.Disposed) + { + ClearRator(); + } + + if (_rator is null) + { + return; + } + + if (!_rator.MoveNext()) + { + ClearRator(); + if (_outstanding == 0) + { + subscription.Observer.OnCompleted(); + } + + return; + } + + _outstanding++; + + // The continuation passes the Subscription as state — already a reference type, so + // no per-task ValueTuple boxing is needed. The static lambda preserves zero closure + // capture. + _rator.Current?.ContinueWith( + static (ant, state) => + { + var sub = (Subscription)state!; + sub.Limiter.ProcessTaskCompletion(sub, ant); + }, + subscription, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + } + + /// Per-subscription handle: holds the observer reference and a disposal latch. + /// Replaces the previous ActionDisposable(() => Disposed = true) pattern with a + /// dedicated class — no closure object per subscribe. + /// The owning limiter. + /// The downstream observer. + internal sealed class Subscription(ConcurrencyLimiter limiter, IObserver observer) : IDisposable + { + /// Gets the owning limiter. + public ConcurrencyLimiter Limiter { get; } = limiter; + + /// Gets the downstream observer. + public IObserver Observer { get; } = observer; + + /// Gets a value indicating whether the subscription has been disposed. + public bool Disposed => Limiter.Disposed; + + /// + public void Dispose() => Limiter.Disposed = true; + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Internal/ConcurrencyRaceHelpers.cs b/src/ReactiveUI.Primitives.Extensions/Internal/ConcurrencyRaceHelpers.cs new file mode 100644 index 0000000..abe1f85 --- /dev/null +++ b/src/ReactiveUI.Primitives.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.Primitives.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 RxVoid-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.Primitives.Extensions/Internal/CurrentValueSubject.cs b/src/ReactiveUI.Primitives.Extensions/Internal/CurrentValueSubject.cs new file mode 100644 index 0000000..2c52084 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Internal/CurrentValueSubject.cs @@ -0,0 +1,320 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Extensions.Internal; + +/// +/// Thread-safe subject that holds the most-recently-emitted value, replays it to new subscribers, +/// and broadcasts subsequent emissions. Per-emission hot path: +/// +/// Lock taken only to read the current observer state and update the cached value. +/// Single-observer state lives in a dedicated field — no array allocated for the common case. +/// Multi-observer state uses a copy-on-write IObserver{T}[] snapshot iterated outside the lock. +/// +/// +/// The element type. +internal sealed class CurrentValueSubject : IObservable, IObserver, IDisposable +{ + /// Lock guarding state mutations; held only across snapshot reads and field writes. + private readonly Lock _gate = new(); + + /// Single-observer fast path; non-null when exactly one observer is subscribed. + private IObserver? _observer; + + /// Multi-observer snapshot; non-null when two or more observers are subscribed. Copy-on-write. + private IObserver[]? _observers; + + /// Latest value, replayed to new subscribers. + private T _value; + + /// Terminal error; non-null once has fired. + private Exception? _error; + + /// Latched when the source has completed. + private bool _completed; + + /// Latched when has been called. + private bool _disposed; + + /// Initializes a new instance of the class with the supplied current value. + /// The value replayed to subscribers until overwrites it. + public CurrentValueSubject(T initialValue) + { + _value = initialValue; + } + + /// Gets the most recently emitted value. + public T Value + { + get + { + lock (_gate) + { + return _value; + } + } + } + + /// + public void OnNext(T value) + { + IObserver? single; + IObserver[]? multi; + lock (_gate) + { + if (_disposed || _completed || _error is not null) + { + return; + } + + _value = value; + single = _observer; + multi = _observers; + } + + if (single is not null) + { + single.OnNext(value); + return; + } + + if (multi is null) + { + return; + } + + for (var i = 0; i < multi.Length; i++) + { + multi[i].OnNext(value); + } + } + + /// + public void OnError(Exception error) + { + ArgumentExceptionHelper.ThrowIfNull(error); + + IObserver? single; + IObserver[]? multi; + lock (_gate) + { + if (_disposed || _completed || _error is not null) + { + return; + } + + _error = error; + single = _observer; + multi = _observers; + _observer = null; + _observers = null; + } + + if (single is not null) + { + single.OnError(error); + return; + } + + if (multi is null) + { + return; + } + + for (var i = 0; i < multi.Length; i++) + { + multi[i].OnError(error); + } + } + + /// + public void OnCompleted() + { + IObserver? single; + IObserver[]? multi; + lock (_gate) + { + if (_disposed || _completed || _error is not null) + { + return; + } + + _completed = true; + single = _observer; + multi = _observers; + _observer = null; + _observers = null; + } + + if (single is not null) + { + single.OnCompleted(); + return; + } + + if (multi is null) + { + return; + } + + for (var i = 0; i < multi.Length; i++) + { + multi[i].OnCompleted(); + } + } + + /// + public IDisposable Subscribe(IObserver observer) + { + ArgumentExceptionHelper.ThrowIfNull(observer); + + lock (_gate) + { + if (_disposed) + { + observer.OnError(new ObjectDisposedException(nameof(CurrentValueSubject))); + return EmptyDisposable.Instance; + } + + if (_error is not null) + { + observer.OnError(_error); + return EmptyDisposable.Instance; + } + + observer.OnNext(_value); + + if (_completed) + { + observer.OnCompleted(); + return EmptyDisposable.Instance; + } + + AddObserverNoLock(observer); + } + + return new Subscription(this, observer); + } + + /// Returns an view that hides the side. + /// A read-only observable view. + public IObservable AsObservable() => new ReadOnlyView(this); + + /// + public void Dispose() + { + lock (_gate) + { + _disposed = true; + _observer = null; + _observers = null; + } + } + + /// Adds an observer to the subscriber state; assumes the caller holds . + /// The observer to attach. + private void AddObserverNoLock(IObserver observer) + { + if (_observer is null && _observers is null) + { + _observer = observer; + return; + } + + if (_observer is not null) + { + _observers = [_observer, observer]; + _observer = null; + return; + } + + var existing = _observers!; + var grown = new IObserver[existing.Length + 1]; + Array.Copy(existing, grown, existing.Length); + grown[existing.Length] = observer; + _observers = grown; + } + + /// Removes the supplied observer from the subscriber state. + /// The observer to detach. + private void Unsubscribe(IObserver observer) + { + lock (_gate) + { + if (ReferenceEquals(_observer, observer)) + { + _observer = null; + return; + } + + var existing = _observers; + if (existing is null) + { + return; + } + + // Subscription.Dispose's Interlocked guard means a given observer reaches Unsubscribe + // at most once, and Subscribe ensures that observer is present in _observers before + // returning. OnError / OnCompleted / Dispose nullify _observers atomically, which the + // is-null check above already short-circuits — so when we get here, IndexOf finds the + // observer by construction. + var index = Array.IndexOf(existing, observer); + + if (existing.Length == 2) + { + // Collapse back to the single-observer fast path. + _observer = index == 0 ? existing[1] : existing[0]; + _observers = null; + return; + } + + var shrunk = new IObserver[existing.Length - 1]; + if (index > 0) + { + Array.Copy(existing, 0, shrunk, 0, index); + } + + if (index < existing.Length - 1) + { + Array.Copy(existing, index + 1, shrunk, index, existing.Length - index - 1); + } + + _observers = shrunk; + } + } + + /// Per-subscription handle that detaches the observer on dispose. Idempotency is + /// enforced via on — + /// the second dispose sees and returns. Eliminates the previous + /// dedicated _disposed int and shaves a field off every subscription. + /// The owning subject. + /// The observer to detach. + private sealed class Subscription(CurrentValueSubject parent, IObserver observer) : IDisposable + { + /// Observer reference captured at attach time; nulled atomically on first dispose. + private IObserver? _observer = observer; + + /// + public void Dispose() + { + var captured = Interlocked.Exchange(ref _observer, null); + if (captured is null) + { + return; + } + + parent.Unsubscribe(captured); + } + } + + /// Read-only observable view that forwards subscription to the owning subject. + /// The owning subject. + private sealed class ReadOnlyView(CurrentValueSubject parent) : IObservable + { + /// + public IDisposable Subscribe(IObserver observer) => parent.Subscribe(observer); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Internal/DrainNotificationKind.cs b/src/ReactiveUI.Primitives.Extensions/Internal/DrainNotificationKind.cs new file mode 100644 index 0000000..b8e1c7f --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Internal/DrainNotificationKind.cs @@ -0,0 +1,18 @@ +// 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.Primitives.Extensions.Internal; + +/// Kind of upstream notification enqueued for a drain pass. +internal enum DrainNotificationKind +{ + /// OnNext with a value. + Next, + + /// OnError with an exception. + Error, + + /// OnCompleted (no value). + Completed, +} diff --git a/src/ReactiveUI.Primitives.Extensions/Internal/FirstAsTaskHelper.cs b/src/ReactiveUI.Primitives.Extensions/Internal/FirstAsTaskHelper.cs new file mode 100644 index 0000000..41aec66 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Internal/FirstAsTaskHelper.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. + +namespace ReactiveUI.Primitives.Extensions.Internal; + +/// +/// Subscribes once and completes the returned with the first emitted value; +/// faults the task on source error or on empty completion. Combines the +/// and the IObserver into a single allocation per call. +/// +internal static class FirstAsTaskHelper +{ + /// Subscribes and resolves a task with the first value. + /// The element type. + /// The source observable. + /// A task that completes with the first value, faults on error, or faults on empty completion. + public static Task FirstAsTask(IObservable source) + { + ArgumentExceptionHelper.ThrowIfNull(source); + var observer = new FirstObserver(); + observer.Subscription = source.Subscribe(observer); + return observer.Task; + } + + /// Combined TaskCompletionSource + IObserver — one heap allocation per call instead of two. + /// The element type. + private sealed class FirstObserver() : TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), IObserver + { + /// Latches to 1 once the task has been settled so subsequent callbacks are no-ops. + private int _settled; + + /// Gets or sets the source subscription so the first-value path can dispose it on completion. + public IDisposable? Subscription { get; set; } + + /// + public void OnNext(T value) + { + if (Interlocked.Exchange(ref _settled, 1) != 0) + { + return; + } + + TrySetResult(value); + Subscription?.Dispose(); + } + + /// + public void OnError(Exception error) + { + if (Interlocked.Exchange(ref _settled, 1) != 0) + { + return; + } + + TrySetException(error); + } + + /// + public void OnCompleted() + { + if (Interlocked.Exchange(ref _settled, 1) != 0) + { + return; + } + + TrySetException(new InvalidOperationException("Sequence contains no elements.")); + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Internal/FirstAsValueTaskHelper.cs b/src/ReactiveUI.Primitives.Extensions/Internal/FirstAsValueTaskHelper.cs new file mode 100644 index 0000000..bd6f0e3 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Internal/FirstAsValueTaskHelper.cs @@ -0,0 +1,113 @@ +// 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.Threading.Tasks.Sources; + +namespace ReactiveUI.Primitives.Extensions.Internal; + +/// +/// -returning counterpart to . Backs the +/// ToHotValueTask extension with a pooled implementation +/// so steady-state callers pay zero allocations after the pool warms up. +/// +/// The element type. +internal static class FirstAsValueTaskHelper +{ + /// Single-slot pool. null when the previous instance is in flight. + private static PooledFirstObserver? _pooled; + + /// Subscribes once and resolves a with the first value. + /// The source observable. + /// A value task that completes with the first value, faults on error, or faults on empty completion. + public static ValueTask FirstAsValueTask(IObservable source) + { + ArgumentExceptionHelper.ThrowIfNull(source); + + var inst = Interlocked.Exchange(ref _pooled, null) ?? new PooledFirstObserver(); + return inst.Begin(source); + } + + /// Pooled combined + . + private sealed class PooledFirstObserver : IValueTaskSource, IObserver + { + /// The reset-able backing store for the machinery. + private ManualResetValueTaskSourceCore _core = new() { RunContinuationsAsynchronously = true }; + + /// Latches to 1 once the source has been settled so subsequent callbacks no-op. + private int _settled; + + /// The upstream subscription, retained so can cancel it on first match. + private IDisposable? _subscription; + + /// Begins a fresh capture cycle: resets the core, subscribes the source, returns the task. + /// The source observable to subscribe to. + /// The value task observers consume to receive the first value. + public ValueTask Begin(IObservable source) + { + _core.Reset(); + _settled = 0; + _subscription = source.Subscribe(this); + return new ValueTask(this, _core.Version); + } + + /// + public void OnNext(T value) + { + if (Interlocked.Exchange(ref _settled, 1) != 0) + { + return; + } + + _subscription?.Dispose(); + _core.SetResult(value); + } + + /// + public void OnError(Exception error) + { + if (Interlocked.Exchange(ref _settled, 1) != 0) + { + return; + } + + _core.SetException(error); + } + + /// + public void OnCompleted() + { + if (Interlocked.Exchange(ref _settled, 1) != 0) + { + return; + } + + _core.SetException(new InvalidOperationException("Sequence contains no elements.")); + } + + /// + ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) => _core.GetStatus(token); + + /// + void IValueTaskSource.OnCompleted( + Action continuation, + object? state, + short token, + ValueTaskSourceOnCompletedFlags flags) => + _core.OnCompleted(continuation, state, token, flags); + + /// + T IValueTaskSource.GetResult(short token) + { + try + { + return _core.GetResult(token); + } + finally + { + _subscription = null; + Interlocked.CompareExchange(ref _pooled, this, null); + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Internal/IDrainTarget.cs b/src/ReactiveUI.Primitives.Extensions/Internal/IDrainTarget.cs new file mode 100644 index 0000000..13cdb89 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Internal/IDrainTarget.cs @@ -0,0 +1,16 @@ +// 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.Primitives.Extensions.Internal; + +/// +/// Implemented by sinks that drive a . The state helper invokes +/// once per scheduled burst via a static scheduler callback, so passing the sink +/// as an keeps the scheduled action allocation-free (no captured closure). +/// +internal interface IDrainTarget +{ + /// Drains the queued notifications on the scheduler thread. + void Drain(); +} diff --git a/src/ReactiveUI.Primitives.Extensions/Internal/IndexedSubscribeHelper.cs b/src/ReactiveUI.Primitives.Extensions/Internal/IndexedSubscribeHelper.cs new file mode 100644 index 0000000..fa8fe00 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Internal/IndexedSubscribeHelper.cs @@ -0,0 +1,73 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Extensions.Internal; + +/// +/// Shared subscription loop for sync operators that fan an indexed callback set across an N-source +/// list (e.g. BooleanReduceObservable, MinMaxObservable). Each call site previously +/// hand-rolled the same loop — index-capture, +/// triple, aggregate — so centralising it here keeps the per-emission +/// closure shape consistent and the duplication off Sonar's CPD radar. +/// +internal static class IndexedSubscribeHelper +{ + /// + /// Subscribes the supplied callbacks to every source in , threading + /// each source's positional index through to the and + /// hooks. The returned disposable disposes every per-source + /// subscription on dispose. + /// + /// The element type of the source observables. + /// The source observables, indexed 0..N-1. + /// Per-source OnNext hook: (index, value). + /// Shared OnError hook (errors from any source forward unchanged). + /// Per-source OnCompleted hook: (index). + /// A composite disposable that releases every per-source subscription on dispose. + public static IDisposable SubscribeIndexed( + IReadOnlyList> sources, + Action onNext, + Action onError, + Action onCompleted) + { + ArgumentExceptionHelper.ThrowIfNull(sources); + ArgumentExceptionHelper.ThrowIfNull(onNext); + ArgumentExceptionHelper.ThrowIfNull(onError); + ArgumentExceptionHelper.ThrowIfNull(onCompleted); + + var composite = new DisposableBag(); + for (var i = 0; i < sources.Count; i++) + { + composite.Add(sources[i].Subscribe(new IndexedObserver(i, onNext, onError, onCompleted))); + } + + return composite; + } + + /// + /// Observer that carries a source index without allocating per-source callback closures. + /// + /// The element type. + /// The source index. + /// Per-source OnNext hook. + /// Shared OnError hook. + /// Per-source OnCompleted hook. + private sealed class IndexedObserver( + int index, + Action onNext, + Action onError, + Action onCompleted) : IObserver + { + /// + public void OnNext(T value) => onNext(index, value); + + /// + public void OnError(Exception error) => onError(error); + + /// + public void OnCompleted() => onCompleted(index); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Internal/InvalidOperationExceptionHelper.cs b/src/ReactiveUI.Primitives.Extensions/Internal/InvalidOperationExceptionHelper.cs new file mode 100644 index 0000000..e975d7b --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Internal/InvalidOperationExceptionHelper.cs @@ -0,0 +1,90 @@ +// 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; +using System.Runtime.CompilerServices; + +namespace ReactiveUI.Primitives.Extensions.Internal; + +/// +/// Provides helper methods for throwing when +/// constructor-supplied state on an operator is missing at the time it is consumed. +/// The thrown message is composed of the captured member name and the caller member +/// (typically Subscribe or the enclosing type), so call sites just pass the +/// field being validated. +/// +[ExcludeFromCodeCoverage] +internal static class InvalidOperationExceptionHelper +{ + /// + /// Throws an if is null. + /// The exception message is composed from the captured argument expression and the + /// caller member, e.g. "'source' was not supplied to 'Subscribe'.". + /// + /// The reference type field to validate as non-null. + /// Captured automatically via . + /// Captured automatically via . + public static void ThrowIfNull( + [NotNull] object? argument, + [CallerArgumentExpression(nameof(argument))] + string? memberName = null, + [CallerMemberName] string? operation = null) + { + if (argument is not null) + { + return; + } + + throw new InvalidOperationException( + $"'{memberName}' was not supplied to '{operation}'."); + } + + /// + /// Validates an argument and returns it if it is not null, otherwise throws an . + /// Designed for use in primary constructor initializers. + /// + /// The type of the argument. + /// The argument to validate. + /// Captured automatically via . + /// Captured automatically via . + /// The non-null argument. + public static T Check( + [NotNull] T? argument, + [CallerArgumentExpression(nameof(argument))] + string? memberName = null, + [CallerMemberName] string? operation = null) + where T : class + { + if (argument is not null) + { + return argument; + } + + throw new InvalidOperationException( + $"'{memberName}' was not supplied to '{operation}'."); + } + + /// + /// Validates a string argument and returns it if it is not null or empty, otherwise throws an . + /// Designed for use in primary constructor initializers. + /// + /// The argument to validate. + /// Captured automatically via . + /// Captured automatically via . + /// The non-null, non-empty argument. + public static string Check( + [NotNull] string? argument, + [CallerArgumentExpression(nameof(argument))] + string? memberName = null, + [CallerMemberName] string? operation = null) + { + if (argument is null || string.IsNullOrEmpty(argument)) + { + throw new InvalidOperationException( + $"'{memberName}' was not supplied to '{operation}'."); + } + + return argument; + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Internal/ObservableSubscribeExtensions.cs b/src/ReactiveUI.Primitives.Extensions/Internal/ObservableSubscribeExtensions.cs new file mode 100644 index 0000000..fb419b6 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Internal/ObservableSubscribeExtensions.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.Primitives.Extensions.Internal; + +/// +/// Internal subscribe helpers that adapt delegate triples to a proper observer. +/// +internal static class ObservableSubscribeExtensions +{ + /// + /// Subscribes using delegate callbacks for OnNext / OnError / OnCompleted. Unique name to + /// avoid the System.Reactive Subscribe(onNext, onError, onCompleted) ambiguity; the + /// delegates are wrapped by the core sink rather than a + /// duplicated observer. + /// + /// The element type. + /// The source observable. + /// Per-value callback. + /// Error callback. + /// Completion callback. + /// The subscription disposable. + public static IDisposable SubscribeCallbacks( + this IObservable source, + Action onNext, + Action onError, + Action onCompleted) => + source.Subscribe(onNext, onError, onCompleted); +} diff --git a/src/ReactiveUI.Primitives.Extensions/Internal/ObserverArrayHelpers.cs b/src/ReactiveUI.Primitives.Extensions/Internal/ObserverArrayHelpers.cs new file mode 100644 index 0000000..d06c64e --- /dev/null +++ b/src/ReactiveUI.Primitives.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.Primitives.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 RxVoid-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.Primitives.Extensions/Internal/ReduceSinkState.cs b/src/ReactiveUI.Primitives.Extensions/Internal/ReduceSinkState.cs new file mode 100644 index 0000000..a776a07 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Internal/ReduceSinkState.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.Primitives.Extensions.Internal; + +/// +/// Shared synchronous reduce-sink state used by BooleanReduceObservable (AllTrue / AllFalse) +/// and MinMaxObservable (Max / Min). Each per-operator sink composes one instance (has-a, not +/// is-a) and adds only its operator-specific OnNext reduce step; the boilerplate gate, value cache, +/// completion bookkeeping, OnError, and OnCompleted bodies all live here in one place. +/// +/// The source element type (must be a struct so TIn? doubles as the +/// "value seen yet?" Optional). +/// The downstream element type the operator emits after reducing. +internal sealed class ReduceSinkState + where TIn : struct +{ + /// Initializes a new instance of the class. + /// The downstream observer. + /// The number of sources. + public ReduceSinkState(IObserver downstream, int count) + { + Downstream = downstream; + Values = new TIn?[count]; + Completed = new bool[count]; + } + + /// Gets the synchronization gate held across every state read/write and every downstream notification. + public Lock Gate { get; } = new(); + + /// Gets the downstream observer that receives reduced values, error, and completion. + public IObserver Downstream { get; } + + /// Gets the per-source latest values; index N is set on first OnNext from source N. + public TIn?[] Values { get; } + + /// Gets the per-source completion bookkeeping. + public bool[] Completed { get; } + + /// Gets or sets the number of sources that have produced at least one value. + public int HasValueCount { get; set; } + + /// Gets or sets the number of sources that have completed. + public int CompletedCount { get; set; } + + /// Gets or sets a value indicating whether the sink has reached its terminal state. + public bool IsDone { get; set; } + + /// Gets a value indicating whether every source has produced at least one value. + public bool AllValuesPresent => HasValueCount >= Values.Length; + + /// + /// Forwards a terminal error to the downstream observer and marks the sink terminal. Idempotent. + /// + /// The error to forward. + public void HandleError(Exception error) + { + lock (Gate) + { + if (IsDone) + { + return; + } + + IsDone = true; + Downstream.OnError(error); + } + } + + /// + /// Records completion of the source at . The combined sequence terminates + /// once every source has completed OR a source completes without ever having emitted a value. + /// + /// The 0-based source index that just completed. + public void HandleCompleted(int index) + { + lock (Gate) + { + if (IsDone || Completed[index]) + { + return; + } + + Completed[index] = true; + CompletedCount++; + + if (CompletedCount == Values.Length || !Values[index].HasValue) + { + IsDone = true; + Downstream.OnCompleted(); + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Internal/ScheduledDrainState.cs b/src/ReactiveUI.Primitives.Extensions/Internal/ScheduledDrainState.cs new file mode 100644 index 0000000..120ab8a --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Internal/ScheduledDrainState.cs @@ -0,0 +1,164 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Extensions.Internal; + +/// +/// Shared queue-and-single-drain marshaller used by the synchronous scheduler-marshalling operator +/// sinks (ObserveOn, Conflate). Each of those sinks previously hand-rolled the same gate, +/// FIFO queue, drain-in-flight flag, terminal flag, enqueue-and-schedule logic, and dequeue loop on top +/// of identical fields; this helper centralises that machinery so the per-sink class only carries the +/// operator-specific notification handling. Notifications are enqueued and a single drain pass is +/// scheduled per burst (rather than one scheduled action per item), and the drain callback carries no +/// captures — the sink is passed through as an . Sinks compose one instance +/// and forward to it; there is no base class and no per-item virtual dispatch. +/// +/// The element type carried by notifications. +/// The scheduler each drain pass runs on. +/// The sink whose the scheduled pass invokes. +internal sealed class ScheduledDrainState(ISequencer scheduler, IDrainTarget target) +{ + /// The FIFO queue of pending upstream notifications. + private readonly Queue _queue = new(); + + /// Upstream subscription handle, recorded via for teardown on dispose. + private IDisposable? _sourceSubscription; + + /// while a drain pass is in flight on the scheduler. + private bool _draining; + + /// once a terminal notification has been delivered or the sink disposed. + private bool _done; + + /// Gets the gate protecting the queue, drain flag, done flag, and the composing sink's own + /// operator-specific state. Sinks lock this directly when guarding their extra fields. + public Lock Gate { get; } = new(); + + /// Gets a value indicating whether the sink has reached a terminal state. Read inside + /// by callers that need to short-circuit once terminated. + public bool Done => _done; + + /// Enqueues an OnNext notification and schedules a drain pass if one isn't already running. + /// The value to forward downstream. + public void EnqueueNext(T value) => Enqueue(new Notification(DrainNotificationKind.Next, value, null)); + + /// Enqueues an OnError notification and schedules a drain pass if one isn't already running. + /// The error to forward downstream. + public void EnqueueError(Exception error) => Enqueue(new Notification(DrainNotificationKind.Error, default!, error)); + + /// Enqueues an OnCompleted notification and schedules a drain pass if one isn't already running. + public void EnqueueCompleted() => Enqueue(new Notification(DrainNotificationKind.Completed, default!, null)); + + /// Records the upstream subscription, or disposes it immediately if the sink is already done. + /// The upstream subscription handle. + public void Attach(IDisposable subscription) + { + lock (Gate) + { + if (!_done) + { + _sourceSubscription = subscription; + return; + } + } + + subscription.Dispose(); + } + + /// Dequeues the next pending notification, clearing the drain flag when the queue empties or + /// the sink has terminated. + /// The dequeued notification when this returns . + /// if a notification was dequeued; otherwise . + public bool TryDequeue(out Notification notification) + { + lock (Gate) + { + if (_done || _queue.Count == 0) + { + _draining = false; + notification = default; + return false; + } + + notification = _queue.Dequeue(); + return true; + } + } + + /// Marks the sink terminated and drops any still-queued notifications. Locks . + public void Terminate() + { + lock (Gate) + { + _done = true; + _queue.Clear(); + } + } + + /// Marks the sink terminated without clearing the queue. Caller must hold ; + /// the still-queued notifications are abandoned because checks the done flag first. + public void MarkDoneLocked() => _done = true; + + /// Begins disposal under : marks the sink done, clears the queue, and detaches + /// the upstream subscription — returned to the caller so it is disposed outside the gate. Returns + /// when already disposed. + /// The upstream subscription to dispose outside the gate, or . + public IDisposable? BeginDispose() + { + lock (Gate) + { + return _done ? null : BeginDisposeLocked(); + } + } + + /// Marks the sink done, clears the queue, and detaches the upstream subscription, returning it for + /// disposal outside the gate. Caller must hold and have confirmed is + /// . Lets a composing sink dispose its own scheduled-work slot atomically with the + /// done transition under the same lock. + /// The upstream subscription to dispose outside the gate, or . + public IDisposable? BeginDisposeLocked() + { + _done = true; + _queue.Clear(); + var subscription = _sourceSubscription; + _sourceSubscription = null; + return subscription; + } + + /// Enqueues a notification; claims and schedules a single drain pass if one isn't running. + /// The notification to forward to the drain loop. + private void Enqueue(in Notification notification) + { + lock (Gate) + { + if (_done) + { + return; + } + + _queue.Enqueue(notification); + if (_draining) + { + return; + } + + _draining = true; + } + + scheduler.Schedule(target, static (_, drainTarget) => + { + drainTarget.Drain(); + return EmptyDisposable.Instance; + }); + } + + /// Discriminated notification payload enqueued for the scheduled drain. + /// The notification kind. + /// The element carried by ; default otherwise. + /// The error carried by ; null otherwise. + internal readonly record struct Notification(DrainNotificationKind Kind, T Value, Exception? Error); +} diff --git a/src/ReactiveUI.Primitives.Extensions/Internal/SequencerPeriodicMixins.cs b/src/ReactiveUI.Primitives.Extensions/Internal/SequencerPeriodicMixins.cs new file mode 100644 index 0000000..85f2f52 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Internal/SequencerPeriodicMixins.cs @@ -0,0 +1,154 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Extensions.Internal; + +/// +/// Periodic scheduling helpers used by migrated extension operators. +/// +internal static class SequencerPeriodicMixins +{ + /// + /// Schedules repeatedly, starting after . + /// + /// The state type. + /// The scheduler used for each tick. + /// The state passed to each tick. + /// The period between ticks. + /// The tick action. + /// A disposable that cancels future ticks. + public static IDisposable SchedulePeriodic( + this ISequencer scheduler, + TState state, + TimeSpan period, + Action action) => + SchedulePeriodic(scheduler, state, period, period, action); + + /// + /// Schedules repeatedly with an explicit first due time. + /// + /// The scheduler used for each tick. + /// The delay before the first tick. + /// The period between ticks. + /// The tick action. + /// A disposable that cancels future ticks. + public static IDisposable SchedulePeriodic( + this ISequencer scheduler, + TimeSpan dueTime, + TimeSpan period, + Action action) => + SchedulePeriodic(scheduler, action, dueTime, period, static tick => tick()); + + /// + /// Schedules a stateful periodic action. + /// + /// The state type. + /// The scheduler used for each tick. + /// The state passed to each tick. + /// The delay before the first tick. + /// The period between ticks. + /// The tick action. + /// A disposable that cancels future ticks. + private static PeriodicSubscription SchedulePeriodic( + ISequencer scheduler, + TState state, + TimeSpan dueTime, + TimeSpan period, + Action action) + { + ArgumentExceptionHelper.ThrowIfNull(scheduler); + ArgumentExceptionHelper.ThrowIfNull(action); + + var subscription = new PeriodicSubscription(scheduler, state, Sequencer.Normalize(period), action); + subscription.ScheduleNext(Sequencer.Normalize(dueTime)); + return subscription; + } + + /// + /// Disposable state for one periodic schedule. Internal (rather than private) so coverage tests can + /// drive directly instead of via reflection. + /// + /// The state type. + internal sealed class PeriodicSubscription : IDisposable + { + /// The scheduler used for each tick. + private readonly ISequencer _scheduler; + + /// The state passed to each tick. + private readonly TState _state; + + /// The period between ticks. + private readonly TimeSpan _period; + + /// The tick action. + private readonly Action _action; + + /// The current scheduled work. + private readonly SwapDisposable _scheduled = new(); + + /// 0 = active, 1 = disposed. + private int _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The scheduler used for each tick. + /// The state passed to each tick. + /// The period between ticks. + /// The tick action. + public PeriodicSubscription(ISequencer scheduler, TState state, TimeSpan period, Action action) + { + _scheduler = scheduler; + _state = state; + _period = period; + _action = action; + } + + /// + /// Schedules the next tick. + /// + /// The delay before the tick. + public void ScheduleNext(TimeSpan dueTime) + { + if (Volatile.Read(ref _disposed) != 0) + { + return; + } + + _scheduled.Disposable = _scheduler.Schedule(this, dueTime, static (_, subscription) => + { + subscription.Tick(); + return EmptyDisposable.Instance; + }); + } + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _scheduled.Dispose(); + } + + /// + /// Runs a tick and schedules the next one when still active. + /// + internal void Tick() + { + if (Volatile.Read(ref _disposed) != 0) + { + return; + } + + _action(_state); + ScheduleNext(_period); + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Internal/SingleValueObservable.cs b/src/ReactiveUI.Primitives.Extensions/Internal/SingleValueObservable.cs new file mode 100644 index 0000000..1805c07 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Internal/SingleValueObservable.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Extensions.Internal; + +/// +/// Synchronously emits a single cached value to each subscriber and completes. Drop-in for +/// the Rx Observable.Return pattern when sharing the observable instance across calls. +/// +/// The element type. +/// The value emitted to every subscriber. +internal sealed class SingleValueObservable(T value) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + ArgumentExceptionHelper.ThrowIfNull(observer); + observer.OnNext(value); + observer.OnCompleted(); + return EmptyDisposable.Instance; + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Internal/TimerSinkState.cs b/src/ReactiveUI.Primitives.Extensions/Internal/TimerSinkState.cs new file mode 100644 index 0000000..d90ae87 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Internal/TimerSinkState.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.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Extensions.Internal; + +/// +/// Shared lock + timer + done-flag triple used by the synchronous timer-driven operator sinks +/// (Debounce-Until, Detect-Stale, Throttle-Distinct, Buffer-Until-Idle, etc.). Each of those sinks +/// previously hand-rolled three identical OnError / OnCompleted / Dispose method bodies on top of +/// the same fields; this helper centralises the bodies so the per-sink class only carries the +/// operator-specific OnNext logic. Sinks compose one instance and forward to it — no base class. +/// +/// The element type the downstream observer receives. +/// The downstream observer terminal callbacks fan out to. +internal sealed class TimerSinkState(IObserver downstream) +{ + /// Gets the gate protecting state transitions and downstream notification. + public Lock Gate { get; } = new(); + + /// Gets the timer slot used by the operator's OnNext logic to schedule deferred emissions. + public SwapDisposable Timer { get; } = new(); + + /// + /// Gets a value indicating whether the sink has reached a terminal state (OnError, OnCompleted, + /// or Dispose). Read inside the gate by callers that need to short-circuit a deferred operation. + /// + public bool Done { get; private set; } + + /// Forwards a terminal error to the downstream observer and tears the sink down. + /// The error to forward. + public void HandleError(Exception error) + { + lock (Gate) + { + if (Done) + { + return; + } + + Done = true; + Timer.Dispose(); + downstream.OnError(error); + } + } + + /// Forwards completion to the downstream observer and tears the sink down. + public void HandleCompleted() + { + lock (Gate) + { + if (Done) + { + return; + } + + Done = true; + Timer.Dispose(); + downstream.OnCompleted(); + } + } + + /// Marks the sink terminal and disposes the timer without forwarding a notification. + public void HandleDispose() + { + lock (Gate) + { + Done = true; + Timer.Dispose(); + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/ObservableSubscriptionExtensions.cs b/src/ReactiveUI.Primitives.Extensions/ObservableSubscriptionExtensions.cs new file mode 100644 index 0000000..dcb1b47 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/ObservableSubscriptionExtensions.cs @@ -0,0 +1,425 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions; + +/// +/// Provides extension methods for subscribing to and handling reactive sequences +/// in a synchronous or blocking manner. These methods offer utility functions +/// to retrieve emitted values, handle completion, and capture errors from observables. +/// +public static class ObservableSubscriptionExtensions +{ + /// The default timeout used by the WaitFor* helpers when no override is supplied. + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); + + /// + /// Subscribes to and returns the last value emitted during + /// the synchronous call. + /// + /// The element type of . + /// The observable to subscribe to. + /// The last emitted value, or if no value was emitted. + public static T? SubscribeGetValue(this IObservable source) + { + ArgumentExceptionHelper.ThrowIfNull(source); + + var sink = new ValueCaptureObserver(); + using var subscription = source.Subscribe(sink); + return sink.Value; + } + + /// + /// Subscribes to a -producing observable, discarding the value. + /// Safe only when the sequence terminates synchronously. + /// + /// The observable to subscribe to. + public static void SubscribeAndComplete(this IObservable source) + { + ArgumentExceptionHelper.ThrowIfNull(source); + + using var subscription = source.Subscribe(NoopObserver.Instance); + } + + /// + /// Subscribes to and returns any error emitted during the + /// synchronous call. + /// + /// The observable to subscribe to. + /// The captured error, or if none. + public static Exception? SubscribeGetError(this IObservable source) => + SubscribeGetError(source); + + /// + /// Subscribes to and returns any error emitted during the + /// synchronous call. + /// + /// The element type of . + /// The observable to subscribe to. + /// The captured error, or if none. + public static Exception? SubscribeGetError(this IObservable source) + { + ArgumentExceptionHelper.ThrowIfNull(source); + + var sink = new ErrorCaptureObserver(); + using var subscription = source.Subscribe(sink); + return sink.Error; + } + + /// + /// Blocks until emits a value, errors, or completes + /// (default 30s timeout). + /// + /// The element type. + /// The observable to subscribe to. + /// The last value emitted before terminal, or if the sequence completed empty. + /// The sequence did not terminate in time. + public static T? WaitForValue(this IObservable source) => + WaitForValueCore(source, null, DefaultTimeout); + + /// + /// Blocks until emits a value, errors, or completes, + /// honoring an explicit . + /// + /// The element type. + /// The observable to subscribe to. + /// The wait timeout. + /// The last value emitted before terminal, or if the sequence completed empty. + /// The sequence did not terminate within . + public static T? WaitForValue(this IObservable source, TimeSpan timeout) => + WaitForValueCore(source, null, timeout); + + /// + /// Blocks until emits a value, errors, or completes, + /// routing the subscribe call through (default 30s timeout). + /// + /// The element type. + /// The observable to subscribe to. + /// Scheduler used to dispatch the subscribe call. + /// The last value emitted before terminal, or if the sequence completed empty. + /// The sequence did not terminate in time. + public static T? WaitForValue(this IObservable source, ISequencer scheduler) => + WaitForValueCore(source, scheduler, DefaultTimeout); + + /// + /// Blocks until emits a value, errors, or completes, + /// routing the subscribe call through with an explicit + /// . + /// + /// The element type. + /// The observable to subscribe to. + /// Scheduler used to dispatch the subscribe call. + /// The wait timeout. + /// The last value emitted before terminal, or if the sequence completed empty. + /// The sequence did not terminate within . + public static T? WaitForValue(this IObservable source, ISequencer scheduler, TimeSpan timeout) => + WaitForValueCore(source, scheduler, timeout); + + /// + /// Blocks until a -producing completes + /// (default 30s timeout); rethrows any captured error. + /// + /// The observable to subscribe to. + public static void WaitForCompletion(this IObservable source) => + WaitForCompletionCore(source, null, DefaultTimeout); + + /// + /// Blocks until a -producing completes, + /// honoring an explicit ; rethrows any captured error. + /// + /// The observable to subscribe to. + /// The wait timeout. + public static void WaitForCompletion(this IObservable source, TimeSpan timeout) => + WaitForCompletionCore(source, null, timeout); + + /// + /// Blocks until a -producing completes, + /// routing the subscribe call through ; rethrows any captured error. + /// + /// The observable to subscribe to. + /// Scheduler used to dispatch the subscribe call. + public static void WaitForCompletion(this IObservable source, ISequencer scheduler) => + WaitForCompletionCore(source, scheduler, DefaultTimeout); + + /// + /// Blocks until a -producing completes, + /// routing the subscribe call through with an explicit + /// ; rethrows any captured error. + /// + /// The observable to subscribe to. + /// Scheduler used to dispatch the subscribe call. + /// The wait timeout. + public static void WaitForCompletion(this IObservable source, ISequencer scheduler, TimeSpan timeout) => + WaitForCompletionCore(source, scheduler, timeout); + + /// + /// Blocks until terminates; returns any captured error + /// (does NOT rethrow). Default 30s timeout. + /// + /// The element type. + /// The observable to subscribe to. + /// The captured error, or if completion was normal. + public static Exception? WaitForError(this IObservable source) => + WaitForErrorCore(source, null, DefaultTimeout); + + /// + /// Blocks until terminates with an explicit ; + /// returns any captured error (does NOT rethrow). + /// + /// The element type. + /// The observable to subscribe to. + /// The wait timeout. + /// The captured error, or if completion was normal. + public static Exception? WaitForError(this IObservable source, TimeSpan timeout) => + WaitForErrorCore(source, null, timeout); + + /// + /// Blocks until terminates, routing the subscribe call + /// through ; returns any captured error (does NOT rethrow). + /// + /// The element type. + /// The observable to subscribe to. + /// Scheduler used to dispatch the subscribe call. + /// The captured error, or if completion was normal. + public static Exception? WaitForError(this IObservable source, ISequencer scheduler) => + WaitForErrorCore(source, scheduler, DefaultTimeout); + + /// + /// Blocks until terminates, routing the subscribe call + /// through with an explicit ; + /// returns any captured error (does NOT rethrow). + /// + /// The element type. + /// The observable to subscribe to. + /// Scheduler used to dispatch the subscribe call. + /// The wait timeout. + /// The captured error, or if completion was normal. + public static Exception? WaitForError(this IObservable source, ISequencer scheduler, TimeSpan timeout) => + WaitForErrorCore(source, scheduler, timeout); + + /// Shared implementation of and its overloads. + /// The element type. + /// The source observable. + /// Optional scheduler for the subscribe call. + /// The wait timeout. + /// The last value emitted before terminal, or . + private static T? WaitForValueCore(IObservable source, ISequencer? scheduler, TimeSpan timeout) + { + ArgumentExceptionHelper.ThrowIfNull(source); + + using ManualResetEventSlim done = new(); + var sink = new BlockingValueObserver(done); + using var subscription = ScheduledSubscribe(source, sink, scheduler); + + if (!done.Wait(timeout)) + { + throw new TimeoutException( + $"WaitForValue timed out after {timeout.TotalSeconds}s."); + } + + return sink.Result; + } + + /// Shared implementation of and its overloads. + /// The source observable. + /// Optional scheduler for the subscribe call. + /// The wait timeout. + private static void WaitForCompletionCore(IObservable source, ISequencer? scheduler, TimeSpan timeout) + { + ArgumentExceptionHelper.ThrowIfNull(source); + + using ManualResetEventSlim done = new(); + var sink = new BlockingTerminalObserver(done); + using var subscription = ScheduledSubscribe(source, sink, scheduler); + + if (!done.Wait(timeout)) + { + throw new TimeoutException( + $"WaitForCompletion timed out after {timeout.TotalSeconds}s."); + } + + if (sink.Error is null) + { + return; + } + + throw sink.Error; + } + + /// Shared implementation of and its overloads. + /// The element type. + /// The source observable. + /// Optional scheduler for the subscribe call. + /// The wait timeout. + /// The captured error, or . + private static Exception? WaitForErrorCore(IObservable source, ISequencer? scheduler, TimeSpan timeout) + { + ArgumentExceptionHelper.ThrowIfNull(source); + + using ManualResetEventSlim done = new(); + var sink = new BlockingTerminalObserver(done); + using var subscription = ScheduledSubscribe(source, sink, scheduler); + + if (!done.Wait(timeout)) + { + throw new TimeoutException( + $"WaitForError timed out after {timeout.TotalSeconds}s."); + } + + return sink.Error; + } + + /// + /// Subscribes to the specified observable using the provided . + /// If a scheduler is specified, the subscription is scheduled; otherwise, the subscription occurs immediately. + /// + /// The type of the elements in . + /// The observable to subscribe to. + /// The observer to receive notifications from the observable. + /// + /// The scheduler on which to execute the subscription logic. If , the subscription occurs directly without scheduling. + /// + /// A disposable representing the subscription. + private static IDisposable ScheduledSubscribe( + IObservable source, + IObserver observer, + ISequencer? scheduler) + { + if (scheduler is null) + { + return source.Subscribe(observer); + } + + var swap = new SwapDisposable(); + var scheduled = scheduler.Schedule(() => swap.Disposable = source.Subscribe(observer)); + return new DisposableBag(scheduled, swap); + } + + /// + /// No-op observer used by to absorb signals + /// without allocating a delegate trio. + /// + /// The element type of the source. + private sealed class NoopObserver : IObserver + { + /// Singleton instance to avoid per-call allocation. + public static readonly NoopObserver Instance = new(); + + /// + public void OnNext(T value) + { + } + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } + } + + /// Observer that captures the last value seen during synchronous subscribe. + /// The element type of the source. + private sealed class ValueCaptureObserver : IObserver + { + /// Gets the captured value, or if none. + public T? Value { get; private set; } + + /// Gets a value indicating whether at least one value was observed. + public bool HasValue { get; private set; } + + /// + public void OnNext(T value) + { + Value = value; + HasValue = true; + } + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } + } + + /// Observer that captures the first error seen during synchronous subscribe. + /// The element type of the source. + private sealed class ErrorCaptureObserver : IObserver + { + /// Gets the captured error, or if none. + public Exception? Error { get; private set; } + + /// + public void OnNext(T value) + { + } + + /// + public void OnError(Exception error) => Error ??= error; + + /// + public void OnCompleted() + { + } + } + + /// + /// Observer used by the value-returning WaitFor path: captures the last value, + /// signals the gate on terminal, swallows errors (caller's timeout / default reflects outcome). + /// + /// The element type of the source. + /// The gate signalled on terminal. + private sealed class BlockingValueObserver(ManualResetEventSlim done) : IObserver + { + /// Gets the most recent value seen. + public T? Result { get; private set; } + + /// + public void OnNext(T value) => Result = value; + + /// + public void OnError(Exception error) => done.Set(); + + /// + public void OnCompleted() => done.Set(); + } + + /// + /// Observer used by the completion / error WaitFor paths: captures any + /// terminal error and signals the gate on terminal. + /// + /// The element type of the source. + /// The gate signalled on terminal. + private sealed class BlockingTerminalObserver(ManualResetEventSlim done) : IObserver + { + /// Gets the captured terminal error, or if completion was normal. + public Exception? Error { get; private set; } + + /// + public void OnNext(T value) + { + } + + /// + public void OnError(Exception error) + { + Error = error; + done.Set(); + } + + /// + public void OnCompleted() => done.Set(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Observables.cs b/src/ReactiveUI.Primitives.Extensions/Observables.cs new file mode 100644 index 0000000..77664c3 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Observables.cs @@ -0,0 +1,24 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions; + +/// +/// Factory methods that build instances. The plural name avoids +/// resolution collisions with other observable factory types at call sites that import multiple +/// reactive namespaces. +/// +public static class Observables +{ + /// + /// Returns an observable sequence that emits a single value and completes synchronously inside + /// . + /// + /// The element type. + /// The value emitted to every subscriber. + /// An observable that emits and completes on subscribe. + public static IObservable Return(T value) => new SingleValueObservable(value); +} diff --git a/src/ReactiveUI.Primitives.Extensions/ObserverExtensions.cs b/src/ReactiveUI.Primitives.Extensions/ObserverExtensions.cs new file mode 100644 index 0000000..550bbd2 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/ObserverExtensions.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.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions; + +/// +/// Extension methods for . +/// +public static class ObserverExtensions +{ + /// + /// Emits each element from to + /// via using a fast-path iteration for known + /// concrete collection types (, arrays, ). + /// Avoids the per-iteration enumerator allocation that foreach over a bare + /// would incur on these common shapes. + /// + /// The type of elements in the source collection. + /// The observer to emit elements to. + /// The source collection to iterate. + public static void FastForEach(this IObserver observer, IEnumerable source) + { + ArgumentExceptionHelper.ThrowIfNull(observer); + ArgumentExceptionHelper.ThrowIfNull(source); + + if (source is T[] array) + { + for (var i = 0; i < array.Length; i++) + { + observer.OnNext(array[i]); + } + + return; + } + + if (source is List fullList) + { + for (var i = 0; i < fullList.Count; i++) + { + observer.OnNext(fullList[i]); + } + + return; + } + + if (source is IList list) + { + for (var i = 0; i < list.Count; i++) + { + observer.OnNext(list[i]); + } + + return; + } + + if (source is IReadOnlyList readOnlyList) + { + for (var i = 0; i < readOnlyList.Count; i++) + { + observer.OnNext(readOnlyList[i]); + } + + return; + } + + foreach (var item in source) + { + observer.OnNext(item); + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/AsSignalObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/AsSignalObservable.cs new file mode 100644 index 0000000..c5e705a --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/AsSignalObservable.cs @@ -0,0 +1,43 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Projection operator that emits for every source +/// element. Replaces the source.Select(_ => RxVoid.Default) pattern, +/// avoiding the per-subscription closure allocation that the projection lambda +/// would otherwise capture. +/// +/// The element type of the source observable (ignored). +/// The source observable whose values are ignored. +internal sealed class AsSignalObservable(IObservable source) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(observer); + return source.Subscribe(new AsSignalObserver(observer)); + } + + /// + /// Forwarding observer that replaces every value with + /// . Error and completion signals pass through unchanged. + /// + /// The downstream observer. + private sealed class AsSignalObserver(IObserver downstream) : IObserver + { + /// + public void OnNext(T value) => downstream.OnNext(RxVoid.Default); + + /// + public void OnError(Exception error) => downstream.OnError(error); + + /// + public void OnCompleted() => downstream.OnCompleted(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/BooleanReduceObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/BooleanReduceObservable.cs new file mode 100644 index 0000000..c2f716f --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/BooleanReduceObservable.cs @@ -0,0 +1,141 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Combines the latest boolean values from multiple sources and emits true iff every latest +/// value equals . Backs both AllTrue (target=true) and AllFalse +/// (target=false) without the array allocations a generic CombineLatest(...).Select(xs => xs.All(...)) +/// pipeline would incur. +/// +/// The source observables. +/// The value every source must hold for the operator to emit true. +internal sealed class BooleanReduceObservable(IEnumerable> sources, bool target) : IObservable +{ + /// The source list. + private readonly IReadOnlyList> _sourceList = MaterializeSources(sources); + + /// + public IDisposable Subscribe(IObserver observer) + { + ArgumentExceptionHelper.ThrowIfNull(observer); + + if (_sourceList.Count == 0) + { + observer.OnNext(true); + observer.OnCompleted(); + return EmptyDisposable.Instance; + } + + var sink = new Sink(observer, _sourceList.Count, target); + return IndexedSubscribeHelper.SubscribeIndexed(_sourceList, sink.OnNext, sink.OnError, sink.OnCompleted); + } + + /// Materializes source enumeration once without using LINQ in shipping code. + /// The sources to materialize. + /// The source list. + private static IReadOnlyList> MaterializeSources(IEnumerable> sources) + { + InvalidOperationExceptionHelper.ThrowIfNull(sources); + + if (sources is IReadOnlyList> readOnlyList) + { + return readOnlyList; + } + + if (sources is ICollection> collection) + { + var materialized = new IObservable[collection.Count]; + collection.CopyTo(materialized, 0); + return materialized; + } + + var buffer = Array.Empty>(); + var count = 0; + foreach (var source in sources) + { + if (count == buffer.Length) + { + var grown = new IObservable[buffer.Length == 0 ? 4 : buffer.Length * 2]; + Array.Copy(buffer, grown, count); + buffer = grown; + } + + buffer[count++] = source; + } + + if (count == buffer.Length) + { + return buffer; + } + + var trimmed = new IObservable[count]; + Array.Copy(buffer, trimmed, count); + return trimmed; + } + + /// + /// Sink that holds the latest value per source and reduces them against . + /// Composes for the shared gate / value cache / OnError / + /// OnCompleted plumbing so this class carries only the per-operator reduce step. + /// + /// The downstream observer. + /// The number of sources. + /// The value every source must hold for emit to be true. + private sealed class Sink(IObserver downstream, int count, bool target) + { + /// Shared gate / value cache / terminal-state plumbing. + private readonly ReduceSinkState _state = new(downstream, count); + + /// Handles OnNext from a source. + /// Source index. + /// Emitted value. + public void OnNext(int index, bool value) + { + lock (_state.Gate) + { + if (_state.IsDone) + { + return; + } + + if (!_state.Values[index].HasValue) + { + _state.HasValueCount++; + } + + _state.Values[index] = value; + + if (!_state.AllValuesPresent) + { + return; + } + + var matches = true; + for (var i = 0; i < _state.Values.Length; i++) + { + if (_state.Values[i] != target) + { + matches = false; + break; + } + } + + _state.Downstream.OnNext(matches); + } + } + + /// Handles OnError from any source. + /// The error. + public void OnError(Exception error) => _state.HandleError(error); + + /// Handles OnCompleted from a source. + /// Source index. + public void OnCompleted(int index) => _state.HandleCompleted(index); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/BufferUntilIdleObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/BufferUntilIdleObservable.cs new file mode 100644 index 0000000..7924520 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/BufferUntilIdleObservable.cs @@ -0,0 +1,110 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Buffers elements and emits them when the stream has been idle for a specified duration. Backs both +/// the BufferUntilIdle and BufferUntilInactive public operators. +/// +/// The type of elements in the source sequence. +/// The source observable. +/// The duration of inactivity required to flush the buffer. +/// The scheduler to run the idle timer on. +internal sealed class BufferUntilIdleObservable( + IObservable source, + TimeSpan idleTime, + ISequencer scheduler) : IObservable> +{ + /// + public IDisposable Subscribe(IObserver> observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(scheduler); + ArgumentExceptionHelper.ThrowIfNull(observer); + + var sink = new BufferUntilIdleSink(observer, idleTime, scheduler); + var subscription = source.Subscribe(sink); + return new DisposableBag(subscription, sink); + } + + /// + /// Sink that manages the buffer and idle timer. Composes for + /// the shared gate / timer / done-flag plumbing so this class only carries the buffer logic. + /// + /// The downstream observer. + /// The idle time period. + /// The scheduler. + private sealed class BufferUntilIdleSink( + IObserver> downstream, + TimeSpan idleTime, + ISequencer scheduler) : IObserver, IDisposable + { + /// Shared gate / timer / done-flag plumbing. + private readonly TimerSinkState> _state = new(downstream); + + /// The current buffer of elements. + private List _buffer = []; + + /// + public void OnNext(T value) + { + lock (_state.Gate) + { + if (_state.Done) + { + return; + } + + _buffer.Add(value); + ScheduleFlush(); + } + } + + /// + public void OnError(Exception error) + { + Flush(); + _state.HandleError(error); + } + + /// + public void OnCompleted() + { + Flush(); + _state.HandleCompleted(); + } + + /// + public void Dispose() => _state.HandleDispose(); + + /// Schedules a flush after the idle period. + private void ScheduleFlush() => _state.Timer.Disposable = scheduler.Schedule(idleTime, Flush); + + /// Flushes the current buffer to the downstream observer. + private void Flush() + { + List? toEmit = null; + lock (_state.Gate) + { + if (_buffer.Count > 0) + { + toEmit = _buffer; + _buffer = []; + } + } + + if (toEmit is null) + { + return; + } + + downstream.OnNext(toEmit); + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/BufferUntilObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/BufferUntilObservable.cs new file mode 100644 index 0000000..35ce50b --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/BufferUntilObservable.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 System.Text; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Operator that buffers characters until a start and end delimiter are found. +/// +/// The source observable of characters. +/// The starting delimiter. +/// The ending delimiter. +internal sealed class BufferUntilObservable( + IObservable source, + char startsWith, + char endsWith) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(observer); + return source.Subscribe(new BufferUntilObserver(observer, startsWith, endsWith)); + } + + /// + /// Observer that buffers characters until delimiters are matched. + /// + /// The downstream observer. + /// Start char. + /// End char. + private sealed class BufferUntilObserver( + IObserver downstream, + char startsWith, + char endsWith) : IObserver + { + /// + /// The string builder. + /// + private readonly StringBuilder _sb = new(); + + /// + /// Whether the start delimiter has been found. + /// + private bool _startFound; + + /// + public void OnNext(char value) + { + if (!_startFound && value != startsWith) + { + return; + } + + _startFound = true; + _sb.Append(value); + + if (value != endsWith) + { + return; + } + + downstream.OnNext(_sb.ToString()); + _startFound = false; + _sb.Clear(); + } + + /// + public void OnError(Exception error) => downstream.OnError(error); + + /// + public void OnCompleted() + { + if (_startFound && _sb.Length > 0) + { + downstream.OnNext(_sb.ToString()); + } + + downstream.OnCompleted(); + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/CachedObservables.cs b/src/ReactiveUI.Primitives.Extensions/Operators/CachedObservables.cs new file mode 100644 index 0000000..495fc1f --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/CachedObservables.cs @@ -0,0 +1,18 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Shared, cached observable singletons for frequently emitted trivial values. +/// +public static class CachedObservables +{ + /// + /// Gets a cached observable that synchronously emits a single and completes. + /// + public static IObservable UnitDefault { get; } = new SingleValueObservable(RxVoid.Default); +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/CatchAndReturnWithFactoryObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/CatchAndReturnWithFactoryObservable.cs new file mode 100644 index 0000000..8428037 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/CatchAndReturnWithFactoryObservable.cs @@ -0,0 +1,72 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Catches the configured exception type, emits a fallback built from the exception, and completes. +/// Other exception types propagate downstream. +/// +/// Element type. +/// Exception type to catch. +/// Upstream source. +/// Builds the fallback from the caught exception. +internal sealed class CatchAndReturnWithFactoryObservable( + IObservable source, + Func fallbackFactory) : IObservable + where TException : Exception +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(fallbackFactory); + ArgumentExceptionHelper.ThrowIfNull(observer); + return source.Subscribe(new CatchAndReturnWithFactoryObserver(observer, fallbackFactory)); + } + + /// + /// Forwarding observer that passes / + /// through and converts a matching into an inline emit of the + /// factory-produced fallback followed by terminal . + /// + /// The downstream observer. + /// The fallback factory. + private sealed class CatchAndReturnWithFactoryObserver( + IObserver downstream, + Func fallbackFactory) : IObserver + { + /// + public void OnNext(T value) => downstream.OnNext(value); + + /// + public void OnError(Exception error) + { + if (error is not TException typed) + { + downstream.OnError(error); + return; + } + + T fallback; + try + { + fallback = fallbackFactory(typed); + } + catch (Exception factoryError) + { + downstream.OnError(factoryError); + return; + } + + downstream.OnNext(fallback); + downstream.OnCompleted(); + } + + /// + public void OnCompleted() => downstream.OnCompleted(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/CatchIgnoreEmptyObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/CatchIgnoreEmptyObservable.cs new file mode 100644 index 0000000..e3a5a64 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/CatchIgnoreEmptyObservable.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Forwards source values; on any source error, swallows it and completes silently. +/// +/// Element type. +/// Upstream source. +internal sealed class CatchIgnoreEmptyObservable(IObservable source) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(observer); + return source.Subscribe(new CatchIgnoreEmptyObserver(observer)); + } + + /// + /// Forwarding observer that passes / + /// through and replaces with terminal + /// . + /// + /// The downstream observer. + private sealed class CatchIgnoreEmptyObserver(IObserver downstream) : IObserver + { + /// + public void OnNext(T value) => downstream.OnNext(value); + + /// + public void OnError(Exception error) => downstream.OnCompleted(); + + /// + public void OnCompleted() => downstream.OnCompleted(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/CatchIgnoreObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/CatchIgnoreObservable.cs new file mode 100644 index 0000000..84960ef --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/CatchIgnoreObservable.cs @@ -0,0 +1,76 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Operator that catches exceptions of a specific type and completes. +/// +/// The type of the elements in the source sequence. +/// The type of the exception to catch. +/// The source observable sequence. +/// Action to invoke when an exception of type occurs. +internal sealed class CatchIgnoreObservable( + IObservable source, + Action errorAction) : IObservable + where TException : Exception +{ + /// + /// The source observable. + /// + private readonly IObservable _source = InvalidOperationExceptionHelper.Check(source); + + /// + /// The action to invoke when an exception occurs. + /// + private readonly Action _errorAction = InvalidOperationExceptionHelper.Check(errorAction); + + /// + public IDisposable Subscribe(IObserver observer) + { + ArgumentExceptionHelper.ThrowIfNull(observer); + return _source.Subscribe(new CatchIgnoreObserver(observer, _errorAction)); + } + + /// + /// Observer that catches specific exceptions. + /// + /// The downstream observer. + /// The error action. + private sealed class CatchIgnoreObserver( + IObserver downstream, + Action errorAction) : IObserver + { + /// + public void OnNext(TSource value) => downstream.OnNext(value); + + /// + public void OnError(Exception error) + { + if (error is TException ex) + { + try + { + errorAction(ex); + } + catch (Exception actionEx) + { + downstream.OnError(actionEx); + return; + } + + downstream.OnCompleted(); + } + else + { + downstream.OnError(error); + } + } + + /// + public void OnCompleted() => downstream.OnCompleted(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/CatchReturnObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/CatchReturnObservable.cs new file mode 100644 index 0000000..4ac24ad --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/CatchReturnObservable.cs @@ -0,0 +1,47 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Forwards source values; on any source error, emits the stored fallback then completes. +/// +/// Element type. +/// Upstream source. +/// Value emitted on the error path. +internal sealed class CatchReturnObservable(IObservable source, T fallback) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(observer); + return source.Subscribe(new CatchReturnObserver(observer, fallback)); + } + + /// + /// Forwarding observer that passes / + /// through and replaces with an inline emit of the stored + /// fallback followed by terminal . + /// + /// The downstream observer receiving the forwarded signals. + /// The fallback value to emit when the source errors. + private sealed class CatchReturnObserver(IObserver downstream, T fallback) : IObserver + { + /// + public void OnNext(T value) => downstream.OnNext(value); + + /// + public void OnError(Exception error) + { + downstream.OnNext(fallback); + downstream.OnCompleted(); + } + + /// + public void OnCompleted() => downstream.OnCompleted(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/ConflateObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/ConflateObservable.cs new file mode 100644 index 0000000..43d22ab --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/ConflateObservable.cs @@ -0,0 +1,263 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Conflates an observable stream by delaying updates that occur within a minimum period. +/// +/// The type of elements in the source sequence. +/// The source observable. +/// The minimum period between emissions. +/// The scheduler to run the conflation on. +internal sealed class ConflateObservable( + IObservable source, + TimeSpan minimumUpdatePeriod, + ISequencer scheduler) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(scheduler); + ArgumentExceptionHelper.ThrowIfNull(observer); + + var sink = new ConflateSink(observer, minimumUpdatePeriod, scheduler); + sink.AttachSourceSubscription(source.Subscribe(sink)); + return sink; + } + + /// + /// Single observer that combines two previously-distinct concerns into one allocation: + /// (1) marshals upstream notifications onto the scheduler thread — delegated to the shared + /// FIFO queue and scheduled drain — and (2) applies the conflate + /// time-window throttle to each notification. End-user-observable + /// semantics are unchanged from the prior two-observer implementation. + /// + internal sealed class ConflateSink : IObserver, IDisposable, IDrainTarget + { + /// The downstream observer. + private readonly IObserver _downstream; + + /// The minimum period between emissions. + private readonly TimeSpan _minimumUpdatePeriod; + + /// The scheduler to run the conflation on. + private readonly ISequencer _scheduler; + + /// Shared queue / gate / scheduled-drain machinery. + private readonly ScheduledDrainState _state; + + /// The disposable tracking a scheduled deferred emission. + private readonly MutableDisposable _updateScheduled = new(); + + /// Wall-clock timestamp of the last emission forwarded downstream. + private DateTimeOffset _lastUpdateTime = DateTimeOffset.MinValue; + + /// when an upstream OnCompleted is queued but a deferred + /// emission is still pending; the completion fires after that emission lands. + private bool _completionRequested; + + /// Initializes a new instance of the class. + /// The downstream observer. + /// The minimum period between emissions. + /// The scheduler to run the conflation on. + public ConflateSink(IObserver downstream, TimeSpan minimumUpdatePeriod, ISequencer scheduler) + { + _downstream = downstream; + _minimumUpdatePeriod = minimumUpdatePeriod; + _scheduler = scheduler; + _state = new ScheduledDrainState(scheduler, this); + } + + /// Records the upstream subscription so can tear it down. + /// The upstream subscription handle. + public void AttachSourceSubscription(IDisposable subscription) => _state.Attach(subscription); + + /// + public void OnNext(T value) => _state.EnqueueNext(value); + + /// + public void OnError(Exception error) => _state.EnqueueError(error); + + /// + public void OnCompleted() => _state.EnqueueCompleted(); + + /// + public void Dispose() + { + IDisposable? subscription; + lock (_state.Gate) + { + if (_state.Done) + { + return; + } + + subscription = _state.BeginDisposeLocked(); + _updateScheduled.Dispose(); + } + + subscription?.Dispose(); + } + + /// + void IDrainTarget.Drain() + { + while (_state.TryDequeue(out var notification)) + { + switch (notification.Kind) + { + case DrainNotificationKind.Next: + { + ProcessNext(notification.Value); + break; + } + + case DrainNotificationKind.Error: + { + ForwardError(notification.Error!); + return; + } + + default: + { + // DrainNotificationKind has only three values; the discard arm absorbs + // Completed so the compiler sees an exhaustive switch. + ForwardCompleted(); + return; + } + } + } + } + + /// Applies the throttle-window decision to a dequeued value and either emits inline or + /// schedules a deferred emission. The emission bodies live in covered helpers; only this + /// race-guarded shell (whose already-done early-out is reachable only when a concurrent dispose + /// flips the flag between the drain dequeue and this gate acquisition) is excluded. + /// The value to forward. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private void ProcessNext(T value) + { + var currentUpdateTime = _scheduler.Now; + bool scheduleRequired; + + lock (_state.Gate) + { + if (_state.Done) + { + return; + } + + scheduleRequired = currentUpdateTime - _lastUpdateTime < _minimumUpdatePeriod; + if (scheduleRequired && _updateScheduled.Disposable != null) + { + _updateScheduled.Disposable.Dispose(); + _updateScheduled.Disposable = null; + } + } + + if (scheduleRequired) + { + ScheduleDeferredEmission(value); + } + else + { + EmitInline(value); + } + } + + /// Schedules a deferred emission of at the end of the throttle window, + /// forwarding a pending completion once it lands. + /// The value to emit when the window elapses. + private void ScheduleDeferredEmission(T value) => + _updateScheduled.Disposable = _scheduler.Schedule( + (Sink: this, Value: value), + _lastUpdateTime + _minimumUpdatePeriod, + static (_, state) => + { + state.Sink.EmitDeferred(state.Value); + return EmptyDisposable.Instance; + }); + + /// Emits a deferred value and forwards a pending completion once the value lands. + /// The deferred value. + private void EmitDeferred(T value) + { + _downstream.OnNext(value); + + lock (_state.Gate) + { + _lastUpdateTime = _scheduler.Now; + _updateScheduled.Disposable = null; + if (_completionRequested) + { + _state.MarkDoneLocked(); + _downstream.OnCompleted(); + } + } + } + + /// Emits immediately and records the emission time. + /// The value to emit. + private void EmitInline(T value) + { + _downstream.OnNext(value); + lock (_state.Gate) + { + _lastUpdateTime = _scheduler.Now; + } + } + + /// Forwards an error to downstream and terminates the sink. + /// The error to forward. + /// The already-terminated early-out is reachable only when a concurrent dispose flips the + /// flag between the drain dequeue and this gate acquisition; excluded as race-only. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private void ForwardError(Exception error) + { + lock (_state.Gate) + { + if (_state.Done) + { + return; + } + + _state.MarkDoneLocked(); + _updateScheduled.Dispose(); + } + + _downstream.OnError(error); + } + + /// Forwards completion, deferring if a throttled emission is still scheduled. + /// The already-terminated early-out is reachable only when a concurrent dispose flips the + /// flag between the drain dequeue and this gate acquisition; excluded as race-only. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private void ForwardCompleted() + { + lock (_state.Gate) + { + if (_state.Done) + { + return; + } + + if (_updateScheduled.Disposable != null) + { + _completionRequested = true; + return; + } + + _state.MarkDoneLocked(); + } + + _downstream.OnCompleted(); + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/DebounceImmediateObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/DebounceImmediateObservable.cs new file mode 100644 index 0000000..7b2d95a --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/DebounceImmediateObservable.cs @@ -0,0 +1,166 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Debounces a sequence but emits the first value immediately. +/// +/// The type of elements in the source sequence. +/// The source observable. +/// The debounce duration. +/// The scheduler to use for timing. +internal sealed class DebounceImmediateObservable( + IObservable source, + TimeSpan dueTime, + ISequencer scheduler) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(scheduler); + ArgumentExceptionHelper.ThrowIfNull(observer); + + var sink = new DebounceImmediateSink(observer, dueTime, scheduler); + var subscription = source.Subscribe(sink); + return new DisposableBag(subscription, sink); + } + + /// + /// Sink for the debounce immediate observable. + /// + /// The downstream observer. + /// The debounce duration. + /// The scheduler to use for timing. + private sealed class DebounceImmediateSink( + IObserver downstream, + TimeSpan dueTime, + ISequencer scheduler) : IObserver, IDisposable + { + /// + /// The gate for thread safety. + /// + private readonly Lock _gate = new(); + + /// + /// The timer for debouncing. + /// + private readonly SwapDisposable _timer = new(); + + /// + /// Whether the first value has been emitted. + /// + private bool _isFirst = true; + + /// + /// The last value received. + /// + private T? _lastValue; + + /// + /// Whether a value is pending. + /// + private bool _hasValue; + + /// + /// Whether the sequence is done. + /// + private bool _done; + + /// + public void OnNext(T value) + { + lock (_gate) + { + if (_done) + { + return; + } + + if (_isFirst) + { + _isFirst = false; + downstream.OnNext(value); + return; + } + + _lastValue = value; + _hasValue = true; + _timer.Disposable = scheduler.Schedule(dueTime, Emit); + } + } + + /// + public void OnError(Exception error) + { + lock (_gate) + { + if (_done) + { + return; + } + + _done = true; + Emit(); + _timer.Dispose(); + downstream.OnError(error); + } + } + + /// + public void OnCompleted() + { + lock (_gate) + { + if (_done) + { + return; + } + + _done = true; + Emit(); + _timer.Dispose(); + downstream.OnCompleted(); + } + } + + /// + public void Dispose() + { + lock (_gate) + { + _done = true; + _timer.Dispose(); + } + } + + /// + /// Emits the last value if any. + /// + private void Emit() + { + T? toEmit; + bool shouldEmit; + + lock (_gate) + { + shouldEmit = _hasValue; + toEmit = _lastValue; + _hasValue = false; + } + + if (!shouldEmit) + { + return; + } + + downstream.OnNext(toEmit!); + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/DebounceUntilObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/DebounceUntilObservable.cs new file mode 100644 index 0000000..ebace51 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/DebounceUntilObservable.cs @@ -0,0 +1,102 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Debounces a sequence until a condition becomes true for an element. +/// +/// The type of elements in the source sequence. +/// The source observable. +/// The debounce duration. +/// The condition to determine if an element should be emitted immediately or debounced. +/// The scheduler to use for timing. +internal sealed class DebounceUntilObservable( + IObservable source, + TimeSpan debounce, + Func condition, + ISequencer scheduler) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(condition); + InvalidOperationExceptionHelper.ThrowIfNull(scheduler); + ArgumentExceptionHelper.ThrowIfNull(observer); + + var sink = new DebounceUntilSink(observer, debounce, condition, scheduler); + var subscription = source.Subscribe(sink); + return new DisposableBag(subscription, sink); + } + + /// + /// Sink for the debounce-until observable. Composes for the + /// shared gate / timer / done-flag plumbing so this class only carries the OnNext logic. + /// + /// The downstream observer. + /// The debounce duration. + /// The condition. + /// The scheduler. + private sealed class DebounceUntilSink( + IObserver downstream, + TimeSpan debounce, + Func condition, + ISequencer scheduler) : IObserver, IDisposable + { + /// Shared gate / timer / done-flag plumbing. + private readonly TimerSinkState _state = new(downstream); + + /// + public void OnNext(T value) + { + lock (_state.Gate) + { + if (_state.Done) + { + return; + } + + if (condition(value)) + { + _state.Timer.Disposable = null; + downstream.OnNext(value); + } + else + { + _state.Timer.Disposable = scheduler.Schedule( + (Sink: this, Value: value), + debounce, + static state => state.Sink.EmitDebounced(state.Value)); + } + } + } + + /// + public void OnError(Exception error) => _state.HandleError(error); + + /// + public void OnCompleted() => _state.HandleCompleted(); + + /// + public void Dispose() => _state.HandleDispose(); + + /// Emits a debounced value when the sink is still active. + /// The debounced value. + private void EmitDebounced(T value) + { + lock (_state.Gate) + { + if (!_state.Done) + { + downstream.OnNext(value); + } + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/DetectStaleObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/DetectStaleObservable.cs new file mode 100644 index 0000000..eb0c6bf --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/DetectStaleObservable.cs @@ -0,0 +1,123 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Detects when a sequence becomes stale (no emissions for a specified period). +/// +/// The type of elements in the source sequence. +/// The source observable. +/// The period after which the sequence is considered stale. +/// The scheduler to run the staleness timer on. +internal sealed class DetectStaleObservable( + IObservable source, + TimeSpan stalenessPeriod, + ISequencer scheduler) : IObservable> +{ + /// + public IDisposable Subscribe(IObserver> observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(scheduler); + ArgumentExceptionHelper.ThrowIfNull(observer); + + var sink = new DetectStaleSink(observer, stalenessPeriod, scheduler); + sink.AttachSourceSubscription(source.Subscribe(sink)); + sink.Initialize(); + return sink; + } + + /// + /// Sink that manages staleness detection. Composes for the + /// shared gate / timer / done-flag plumbing so this class only carries the OnNext / schedule logic. + /// + /// The downstream observer. + /// The staleness period. + /// The scheduler. + private sealed class DetectStaleSink( + IObserver> downstream, + TimeSpan stalenessPeriod, + ISequencer scheduler) : IObserver, IDisposable + { + /// Shared gate / timer / done-flag plumbing. + private readonly TimerSinkState> _state = new(downstream); + + /// Upstream subscription handle, set once via + /// so the sink can tear it down on dispose without a wrapper bag. + private IDisposable? _sourceSubscription; + + /// Records the upstream subscription for disposal. + /// The upstream subscription handle. + public void AttachSourceSubscription(IDisposable subscription) + { + lock (_state.Gate) + { + if (_state.Done) + { + subscription.Dispose(); + return; + } + + _sourceSubscription = subscription; + } + } + + /// Initializes the staleness timer. + public void Initialize() => ScheduleStale(); + + /// + public void OnNext(T value) + { + lock (_state.Gate) + { + if (_state.Done) + { + return; + } + + downstream.OnNext(new Stale(value)); + ScheduleStale(); + } + } + + /// + public void OnError(Exception error) => _state.HandleError(error); + + /// + public void OnCompleted() => _state.HandleCompleted(); + + /// + public void Dispose() + { + _state.HandleDispose(); + Interlocked.Exchange(ref _sourceSubscription, null)?.Dispose(); + } + + /// Schedules the staleness notification. Uses the state-carrying scheduler + /// overload with a static lambda so no per-reschedule closure capturing this is + /// allocated (the timer re-arms on every upstream emission). + private void ScheduleStale() => + _state.Timer.Disposable = scheduler.Schedule(this, stalenessPeriod, static (_, self) => self.OnStaleTimer()); + + /// Fires the stale marker downstream when the staleness window elapses. + /// The singleton empty disposable for the scheduler contract. + private EmptyDisposable OnStaleTimer() + { + lock (_state.Gate) + { + if (!_state.Done) + { + downstream.OnNext(new Stale()); + } + } + + return EmptyDisposable.Instance; + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/DoOnDisposeObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/DoOnDisposeObservable.cs new file mode 100644 index 0000000..c64593f --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/DoOnDisposeObservable.cs @@ -0,0 +1,59 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Executes an action when the subscription is disposed. +/// +/// The type of elements in the source sequence. +/// The source observable. +/// The action to execute when the subscription is disposed. +internal sealed class DoOnDisposeObservable( + IObservable source, + Action disposeAction) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(disposeAction); + ArgumentExceptionHelper.ThrowIfNull(observer); + + return new DoOnDisposeSubscription(source.Subscribe(observer), disposeAction); + } + + /// + /// Per-subscribe disposal handle that forwards to the source + /// subscription and then to the caller-supplied action. Dedicated class instead of the + /// previous ActionDisposable(() => …) form so no closure is allocated per subscribe. + /// + /// The upstream subscription disposed before the action fires. + /// The action executed once after the upstream is disposed. + private sealed class DoOnDisposeSubscription(IDisposable subscription, Action disposeAction) : IDisposable + { + /// Latches to 1 on the first dispose so the action fires exactly once. + private int _disposed; + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + try + { + subscription.Dispose(); + } + finally + { + disposeAction(); + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/DoOnSubscribeObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/DoOnSubscribeObservable.cs new file mode 100644 index 0000000..6d1ad98 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/DoOnSubscribeObservable.cs @@ -0,0 +1,29 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Executes an action at subscription time. +/// +/// The type of elements in the source sequence. +/// The source observable. +/// The action to execute when subscribed. +internal sealed class DoOnSubscribeObservable( + IObservable source, + Action action) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(action); + ArgumentExceptionHelper.ThrowIfNull(observer); + + action(); + return source.Subscribe(observer); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/DropIfBusyObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/DropIfBusyObservable.cs new file mode 100644 index 0000000..23b6176 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/DropIfBusyObservable.cs @@ -0,0 +1,118 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Operator that drops source elements while an asynchronous action is in progress. +/// Replaces the closure-based implementation in ReactiveExtensions.DropIfBusy. +/// +/// The element type. +/// The source observable. +/// The asynchronous action to execute for each forwarded element. +internal sealed class DropIfBusyObservable( + IObservable source, + Func asyncAction) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(asyncAction); + ArgumentExceptionHelper.ThrowIfNull(observer); + var sink = new DropIfBusySink(observer, asyncAction); + var sub = source.Subscribe(sink); + return new DisposableBag(sub, sink); + } + + /// + /// Sink that manages the busy state and executes the async action. + /// + /// The downstream observer. + /// The async action to run. + private sealed class DropIfBusySink( + IObserver downstream, + Func asyncAction) : IObserver, IDisposable + { + /// 0 = idle, 1 = busy. + private int _isBusy; + + /// Whether the sink is terminal. + private bool _done; + + /// + public void OnNext(T value) + { + if (_done) + { + return; + } + + // If we can transition from 0 to 1, we handle this value. + if (Interlocked.CompareExchange(ref _isBusy, 1, 0) != 0) + { + return; + } + + _ = ProcessAsync(value); + } + + /// + public void OnError(Exception error) + { + if (_done) + { + return; + } + + _done = true; + downstream.OnError(error); + } + + /// + public void OnCompleted() + { + _done = true; + downstream.OnCompleted(); + } + + /// + public void Dispose() => _done = true; + + /// + /// Executes the async action and manages the busy state transition. + /// + /// The value to process. + /// A task representing the async operation. + private async Task ProcessAsync(T value) + { + try + { + await asyncAction(value).ConfigureAwait(false); + if (_done) + { + return; + } + + downstream.OnNext(value); + } + catch (Exception ex) + { + if (_done) + { + return; + } + + downstream.OnError(ex); + } + finally + { + Volatile.Write(ref _isBusy, 0); + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/FilterRegexObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/FilterRegexObservable.cs new file mode 100644 index 0000000..ac18c77 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/FilterRegexObservable.cs @@ -0,0 +1,60 @@ +// 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; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Operator that filters string elements using a . +/// Replaces the closure-based implementation in ReactiveExtensions.Filter. +/// +/// The source observable emitting strings. +/// The regex to use for filtering. +internal sealed class FilterRegexObservable( + IObservable source, + Regex regex) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(regex); + ArgumentExceptionHelper.ThrowIfNull(observer); + return source.Subscribe(new FilterRegexObserver(observer, regex)); + } + + /// + /// Observer that filters strings using regex. + /// + /// The downstream observer receiving strings that match the regex. + /// The regex used for filtering. + private sealed class FilterRegexObserver( + IObserver downstream, + Regex regex) : IObserver + { + /// + public void OnNext(string value) + { + try + { + if (value is not null && regex.IsMatch(value)) + { + downstream.OnNext(value); + } + } + catch (Exception ex) + { + downstream.OnError(ex); + } + } + + /// + public void OnError(Exception error) => downstream.OnError(error); + + /// + public void OnCompleted() => downstream.OnCompleted(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/FirstMatchFromCandidatesObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/FirstMatchFromCandidatesObservable.cs new file mode 100644 index 0000000..970ff90 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/FirstMatchFromCandidatesObservable.cs @@ -0,0 +1,369 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Walks a list of candidate keys sequentially, projects each into a one-shot +/// , transforms the raw value into +/// , and emits the first transformed value that +/// satisfies a predicate. Errors from individual projections are swallowed (the +/// candidate is skipped and the next one is tried). If no candidate matches, +/// completes with a single emission of . +/// +/// +/// Subscribe attempts a synchronous fast-path first: each candidate's +/// projection is subscribed and, if it completes inline, the transform + predicate +/// run on the calling thread with zero additional allocations. Only when a +/// projection completes asynchronously does the method allocate an +/// to track state across callbacks. +/// +/// The type of candidate keys. +/// The element type emitted by the projected observable. +/// The final result type emitted to downstream after transformation. +/// The ordered list of candidate keys to walk. +/// Projects a candidate key into a one-shot observable of raw values. +/// Synchronous transform applied to each raw value to produce the result. +/// Returns when a transformed value is a match. +/// Value emitted when no candidate matches. +internal sealed class FirstMatchFromCandidatesObservable( + IReadOnlyList candidates, + Func> project, + Func transform, + Func predicate, + TResult fallback) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(candidates); + InvalidOperationExceptionHelper.ThrowIfNull(project); + InvalidOperationExceptionHelper.ThrowIfNull(transform); + InvalidOperationExceptionHelper.ThrowIfNull(predicate); + ArgumentExceptionHelper.ThrowIfNull(observer); + + if (candidates.Count == 0) + { + observer.OnNext(fallback); + observer.OnCompleted(); + return EmptyDisposable.Instance; + } + + return TrySyncLoop(observer); + } + + /// + /// Tries a synchronous fast-path: each candidate's projected observable is. + /// + /// The downstream observer. + /// The subscription disposable. + internal IDisposable TrySyncLoop(IObserver observer) + { + // Reuse the SyncProbe across subscribes on the current thread — it carries no + // per-call state once Reset, so per-cycle allocation drops to zero on the fast path. + // Race-free because the field is [ThreadStatic]; only one TrySyncLoop call can be + // active per thread. + var probe = SyncProbe.RentForCurrentThread(); + + for (var i = 0; i < candidates.Count; i++) + { + TResult transformed; + try + { + var projected = project(candidates[i]); + + probe.Reset(); + var sub = projected.Subscribe(probe); + + if (!probe.Completed) + { + sub.Dispose(); + var sink = new AsyncSink(observer, candidates, project, transform, predicate, fallback, i); + sink.TryNext(); + SyncProbe.ReturnToCurrentThread(probe); + return sink; + } + + sub.Dispose(); + + if (probe.Errored || !probe.HasValue) + { + continue; + } + + transformed = transform(probe.Value!); + } + catch + { + continue; + } + + if (predicate(transformed)) + { + observer.OnNext(transformed); + observer.OnCompleted(); + SyncProbe.ReturnToCurrentThread(probe); + return EmptyDisposable.Instance; + } + } + + observer.OnNext(fallback); + observer.OnCompleted(); + SyncProbe.ReturnToCurrentThread(probe); + return EmptyDisposable.Instance; + } + + /// + /// Lightweight observer used by the synchronous fast-path to capture the result + /// of a one-shot projection. Cheaper than because it + /// carries no downstream observer, candidate list, or delegate references. + /// + internal sealed class SyncProbe : IObserver + { + /// Per-thread cached instance; rented on entry to TrySyncLoop and returned + /// on exit. Eliminates the per-subscribe allocation on the fast path. + [ThreadStatic] + private static SyncProbe? _cached; + + /// Gets a value indicating whether OnNext was called. + internal bool HasValue { get; private set; } + + /// Gets a value indicating whether OnError was called. + internal bool Errored { get; private set; } + + /// Gets a value indicating whether OnCompleted or OnError was called. + internal bool Completed { get; private set; } + + /// Gets the value received via OnNext. + internal TRaw? Value { get; private set; } + + /// Rents a probe from the current-thread cache, allocating only if the slot is empty. + /// A fresh-reset probe ready for use. + public static SyncProbe RentForCurrentThread() + { + var rented = _cached; + if (rented is null) + { + return new SyncProbe(); + } + + _cached = null; + rented.Reset(); + return rented; + } + + /// Returns a probe to the current-thread cache for reuse on the next call. + /// The probe instance to cache. + public static void ReturnToCurrentThread(SyncProbe probe) => _cached = probe; + + /// + public void OnNext(TRaw value) + { + Value = value; + HasValue = true; + } + + /// + public void OnError(Exception error) + { + Errored = true; + Completed = true; + } + + /// + public void OnCompleted() => Completed = true; + + /// Resets state for reuse across candidates. + internal void Reset() + { + HasValue = false; + Errored = false; + Completed = false; + Value = default; + } + } + + /// + /// Heap-allocated observer used when a projection does not complete synchronously. + /// Walks the remaining candidates via async callbacks. + /// + /// The downstream observer. + /// The candidate list. + /// The projection delegate. + /// The transform delegate. + /// The match predicate. + /// The fallback value. + /// Index of the first candidate to try. + private sealed class AsyncSink( + IObserver downstream, + IReadOnlyList candidates, + Func> project, + Func transform, + Func predicate, + TResult fallback, + int startIndex) : IObserver, IDisposable + { + /// The current candidate index. + private int _index = startIndex; + + /// The subscription to the current candidate's projected observable. + private IDisposable? _currentSubscription; + + /// Whether the sink has reached a terminal state. + private bool _done; + + /// Whether the sink is currently looping through candidates. + private bool _looping; + + /// + public void OnNext(TRaw value) + { + if (_done) + { + return; + } + + TResult transformed; + try + { + transformed = transform(value); + } + catch + { + return; + } + + if (!predicate(transformed)) + { + return; + } + + _done = true; + downstream.OnNext(transformed); + downstream.OnCompleted(); + } + + /// + public void OnError(Exception error) + { + if (_done) + { + return; + } + + if (_looping) + { + // Sync-completion is captured by the probe in TryNext; nothing more to do here. + return; + } + + TryNext(); + } + + /// + public void OnCompleted() + { + if (_done) + { + return; + } + + if (_looping) + { + // Sync-completion is captured by the probe in TryNext; nothing more to do here. + return; + } + + TryNext(); + } + + /// + public void Dispose() + { + _done = true; + Interlocked.Exchange(ref _currentSubscription, null)?.Dispose(); + } + + /// + /// Subscribes to the next candidate's projected observable, or emits the + /// fallback if no candidates remain. + /// + internal void TryNext() + { + _looping = true; + try + { + while (!_done && _index < candidates.Count) + { + var key = candidates[_index++]; + + IObservable projected; + try + { + projected = project(key); + } + catch + { + continue; + } + + var probe = new CompletionFlagObserver(this); + var sub = projected.Subscribe(probe); + Interlocked.Exchange(ref _currentSubscription, sub); + + if (!probe.Completed) + { + return; + } + } + } + finally + { + _looping = false; + } + + if (_done) + { + return; + } + + _done = true; + downstream.OnNext(fallback); + downstream.OnCompleted(); + } + + /// + /// Forwarding that records whether a terminal notification + /// (OnError / OnCompleted) arrived synchronously during the Subscribe + /// call. Replaces the prior _syncCompleted field on so the + /// flag is per-iteration state rather than instance state. + /// + /// The wrapped sink that receives forwarded notifications. + private sealed class CompletionFlagObserver(IObserver inner) : IObserver + { + /// Gets a value indicating whether a terminal notification was observed. + public bool Completed { get; private set; } + + /// + public void OnNext(TRaw value) => inner.OnNext(value); + + /// + public void OnError(Exception error) + { + Completed = true; + inner.OnError(error); + } + + /// + public void OnCompleted() + { + Completed = true; + inner.OnCompleted(); + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/ForEachObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/ForEachObservable.cs new file mode 100644 index 0000000..717e282 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/ForEachObservable.cs @@ -0,0 +1,59 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Flattening operator that subscribes to an upstream sequence of +/// batches and emits each contained element +/// individually. Replaces the +/// Observable.Create<T>(o => source.ObserveOnSafe(s).Subscribe(v => o.FastForEach(v))) +/// pattern with a single operator + observer pair. +/// +/// The element type emitted to the downstream observer. +/// The source observable of enumerables. +/// An optional scheduler used to marshal source notifications onto. +internal sealed class ForEachObservable( + IObservable> source, + ISequencer? scheduler) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(observer); + + var observed = scheduler is null ? source : new ObserveOnObservable>(source, scheduler); + return observed.Subscribe(new ForEachObserver(observer)); + } + + /// + /// Forwarding observer that fans each batch element out to the downstream + /// via + /// and passes terminal signals through. + /// + /// The downstream observer. + private sealed class ForEachObserver(IObserver downstream) : IObserver> + { + /// + public void OnNext(IEnumerable value) + { + if (value is null) + { + return; + } + + downstream.FastForEach(value); + } + + /// + public void OnError(Exception error) => downstream.OnError(error); + + /// + public void OnCompleted() => downstream.OnCompleted(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/FromArrayObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/FromArrayObservable.cs new file mode 100644 index 0000000..1888ee4 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/FromArrayObservable.cs @@ -0,0 +1,69 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Factory operator that emits each element of an +/// to a single subscriber, then completes. The emission can optionally be +/// marshalled through a scheduler. Replaces the +/// Observable.Create<T>(o => scheduler.ScheduleSafe(() => o.FastForEach(source))) +/// pattern with a dedicated implementation. +/// +/// The element type emitted to the downstream observer. +/// The source enumerable whose elements are pumped on subscription. +/// An optional scheduler used to dispatch the pump. +internal sealed class FromArrayObservable( + IEnumerable source, + ISequencer? scheduler) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(observer); + + if (scheduler is null) + { + Pump(observer, source); + return EmptyDisposable.Instance; + } + + var capturedObserver = observer; + var capturedSource = source; + return scheduler.Schedule( + (capturedObserver, capturedSource), + static (_, state) => + { + Pump(state.capturedObserver, state.capturedSource); + return EmptyDisposable.Instance; + }); + } + + /// + /// Pumps each element of into + /// and signals completion. Errors thrown during enumeration are forwarded + /// to . + /// + /// The downstream observer. + /// The elements to pump. + private static void Pump(IObserver observer, IEnumerable elements) + { + try + { + observer.FastForEach(elements); + } + catch (Exception error) + { + observer.OnError(error); + return; + } + + observer.OnCompleted(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/HeartbeatObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/HeartbeatObservable.cs new file mode 100644 index 0000000..46dd6a4 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/HeartbeatObservable.cs @@ -0,0 +1,168 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Injects heartbeat values into the sequence when the source remains quiet for a specified period. +/// +/// The type of elements in the source sequence. +/// The source observable. +/// The period between heartbeats. +/// The scheduler to run the heartbeat timer on. +internal sealed class HeartbeatObservable( + IObservable source, + TimeSpan heartbeatPeriod, + ISequencer scheduler) : IObservable> +{ + /// + public IDisposable Subscribe(IObserver> observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(scheduler); + ArgumentExceptionHelper.ThrowIfNull(observer); + + var sink = new HeartbeatSink(observer, heartbeatPeriod, scheduler); + sink.AttachSourceSubscription(source.Subscribe(sink)); + sink.Initialize(); + return sink; + } + + /// + /// The sink for the heartbeat operator. + /// + /// The downstream observer. + /// The period between heartbeats. + /// The scheduler to run the heartbeat timer on. + private sealed class HeartbeatSink( + IObserver> downstream, + TimeSpan heartbeatPeriod, + ISequencer scheduler) : IObserver, IDisposable + { + /// + /// The gate to synchronize access to the sink's state. + /// + private readonly Lock _gate = new(); + + /// + /// The subscription to the periodic heartbeat timer. + /// + private readonly MutableDisposable _timerSubscription = new(); + + /// Upstream subscription handle; set once via + /// so the sink can tear it down in without needing a wrapper bag. + private IDisposable? _sourceSubscription; + + /// + /// Whether the sink has completed or been disposed. + /// + private bool _done; + + /// Records the upstream subscription so can tear it down. + /// The upstream subscription handle. + public void AttachSourceSubscription(IDisposable subscription) + { + lock (_gate) + { + if (_done) + { + subscription.Dispose(); + return; + } + + _sourceSubscription = subscription; + } + } + + /// + /// Initializes the heartbeat timer. + /// + public void Initialize() => ScheduleHeartbeats(); + + /// + public void OnNext(T value) + { + lock (_gate) + { + if (_done) + { + return; + } + + downstream.OnNext(new Heartbeat(value)); + ScheduleHeartbeats(); + } + } + + /// + public void OnError(Exception error) + { + lock (_gate) + { + if (_done) + { + return; + } + + _done = true; + _timerSubscription.Dispose(); + downstream.OnError(error); + } + } + + /// + public void OnCompleted() + { + lock (_gate) + { + if (_done) + { + return; + } + + _done = true; + _timerSubscription.Dispose(); + downstream.OnCompleted(); + } + } + + /// + public void Dispose() + { + IDisposable? subscription; + lock (_gate) + { + _done = true; + _timerSubscription.Dispose(); + subscription = _sourceSubscription; + _sourceSubscription = null; + } + + subscription?.Dispose(); + } + + /// + /// Schedules the next heartbeat. + /// + private void ScheduleHeartbeats() + { + lock (_gate) + { + if (_done) + { + return; + } + + _timerSubscription.Disposable = scheduler.SchedulePeriodic( + downstream, + heartbeatPeriod, + static d => d.OnNext(new Heartbeat())); + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/LatestOrDefaultObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/LatestOrDefaultObservable.cs new file mode 100644 index 0000000..4b95a30 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/LatestOrDefaultObservable.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.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Emits the latest value from the source sequence or a default value if no value has been emitted. +/// +/// The type of elements in the source sequence. +/// The source observable. +/// The value to emit initially. +internal sealed class LatestOrDefaultObservable( + IObservable source, + T defaultValue) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(observer); + + var sink = new LatestOrDefaultSink(observer, defaultValue); + sink.Initialize(); + return source.Subscribe(sink); + } + + /// + /// Sink that implements the latest or default logic. + /// + /// The observer to forward elements to. + /// The value to emit initially. + private sealed class LatestOrDefaultSink(IObserver downstream, T defaultValue) : IObserver + { + /// + /// The last value emitted. + /// + private T? _last = defaultValue; + + /// + /// Whether any value has been emitted yet. + /// + private bool _hasEmitted; + + /// + /// Initializes the sink by emitting the default value. + /// + public void Initialize() + { + downstream.OnNext(_last!); + _hasEmitted = true; + } + + /// + public void OnNext(T value) + { + if (_hasEmitted && EqualityComparer.Default.Equals(value, _last!)) + { + return; + } + + _last = value; + _hasEmitted = true; + downstream.OnNext(value); + } + + /// + public void OnError(Exception error) => downstream.OnError(error); + + /// + public void OnCompleted() => downstream.OnCompleted(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/LogErrorsObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/LogErrorsObservable.cs new file mode 100644 index 0000000..e64103e --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/LogErrorsObservable.cs @@ -0,0 +1,43 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Forwards source values verbatim, invokes the logger on error, then propagates the error downstream. +/// +/// Element type. +/// Upstream source. +/// Invoked once with the source error before downstream sees it. +internal sealed class LogErrorsObservable(IObservable source, Action logger) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(observer); + return source.Subscribe(new LogErrorsObserver(observer, logger)); + } + + /// Per-subscription observer that taps errors through the logger. + /// The downstream observer. + /// Logger invoked on the error path. + private sealed class LogErrorsObserver(IObserver downstream, Action logger) : IObserver + { + /// + public void OnNext(T value) => downstream.OnNext(value); + + /// + public void OnError(Exception error) + { + logger(error); + downstream.OnError(error); + } + + /// + public void OnCompleted() => downstream.OnCompleted(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/MinMaxObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/MinMaxObservable.cs new file mode 100644 index 0000000..2f017c6 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/MinMaxObservable.cs @@ -0,0 +1,99 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Combines the latest values from multiple sources and emits either the maximum or minimum on each +/// tick. Backs both the Max (=true) and Min +/// (=false) operators without the array allocations a generic +/// CombineLatest(...).Select(xs => xs.Max()) pipeline would incur. +/// +/// The value type. +/// The source observables. +/// true to emit the maximum; false to emit the minimum. +internal sealed class MinMaxObservable(IReadOnlyList> sources, bool emitMaximum) : IObservable + where T : struct, IComparable +{ + /// The source list. + private readonly IReadOnlyList> _sourceList = InvalidOperationExceptionHelper.Check(sources); + + /// + public IDisposable Subscribe(IObserver observer) + { + ArgumentExceptionHelper.ThrowIfNull(observer); + + if (_sourceList.Count == 0) + { + observer.OnCompleted(); + return EmptyDisposable.Instance; + } + + var sink = new Sink(observer, _sourceList.Count, emitMaximum); + return IndexedSubscribeHelper.SubscribeIndexed(_sourceList, sink.OnNext, sink.OnError, sink.OnCompleted); + } + + /// + /// Sink that holds the latest value per source and emits either the max or the min. Composes + /// for the shared plumbing. + /// + /// The downstream observer. + /// The number of sources. + /// true for max; false for min. + private sealed class Sink(IObserver downstream, int count, bool emitMaximum) + { + /// Shared gate / value cache / terminal-state plumbing. + private readonly ReduceSinkState _state = new(downstream, count); + + /// Handles OnNext from a source. + /// Source index. + /// Emitted value. + public void OnNext(int index, T value) + { + lock (_state.Gate) + { + if (_state.IsDone) + { + return; + } + + if (!_state.Values[index].HasValue) + { + _state.HasValueCount++; + } + + _state.Values[index] = value; + + if (!_state.AllValuesPresent) + { + return; + } + + var result = _state.Values[0]!.Value; + for (var i = 1; i < _state.Values.Length; i++) + { + var current = _state.Values[i]!.Value; + var cmp = current.CompareTo(result); + if (emitMaximum ? cmp > 0 : cmp < 0) + { + result = current; + } + } + + _state.Downstream.OnNext(result); + } + } + + /// Handles OnError from any source. + /// The error. + public void OnError(Exception error) => _state.HandleError(error); + + /// Handles OnCompleted from a source. + /// Source index. + public void OnCompleted(int index) => _state.HandleCompleted(index); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/NotObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/NotObservable.cs new file mode 100644 index 0000000..fd1e530 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/NotObservable.cs @@ -0,0 +1,40 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Boolean negation operator. Replaces the source.Select(b => !b) +/// pattern with a dedicated forwarding observer, avoiding the per-subscription +/// closure allocation that the projection lambda would otherwise capture. +/// +/// The boolean source observable. +internal sealed class NotObservable(IObservable source) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(observer); + return source.Subscribe(new NotObserver(observer)); + } + + /// + /// Forwarding observer that negates every boolean . + /// + /// The downstream observer. + private sealed class NotObserver(IObserver downstream) : IObserver + { + /// + public void OnNext(bool value) => downstream.OnNext(!value); + + /// + public void OnError(Exception error) => downstream.OnError(error); + + /// + public void OnCompleted() => downstream.OnCompleted(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/ObserveOnIfObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/ObserveOnIfObservable.cs new file mode 100644 index 0000000..d90bd6a --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/ObserveOnIfObservable.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.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Conditionally switches between two schedulers based on a reactive condition. +/// +/// The type of elements in the source sequence. +/// The source observable. +/// The reactive condition observable. +/// The scheduler to use when condition is true. +/// The scheduler to use when condition is false. +internal sealed class ObserveOnIfObservable( + IObservable source, + IObservable condition, + ISequencer trueScheduler, + ISequencer falseScheduler) : IObservable +{ + /// + /// The source observable. + /// + private readonly IObservable _source = InvalidOperationExceptionHelper.Check(source); + + /// + /// The reactive condition observable. + /// + private readonly IObservable _condition = InvalidOperationExceptionHelper.Check(condition); + + /// + /// The scheduler to use when condition is true. + /// + private readonly ISequencer _trueScheduler = InvalidOperationExceptionHelper.Check(trueScheduler); + + /// + /// The scheduler to use when condition is false. + /// + private readonly ISequencer _falseScheduler = InvalidOperationExceptionHelper.Check(falseScheduler); + + /// + public IDisposable Subscribe(IObserver observer) + { + ArgumentExceptionHelper.ThrowIfNull(observer); + + var sink = new ObserveOnIfSink(observer, _trueScheduler, _falseScheduler); + var conditionSub = _condition.Subscribe(sink.ConditionObserver); + var sourceSub = _source.Subscribe(sink); + return new DisposableBag(sourceSub, conditionSub, sink); + } + + /// + /// Sinks the source observable and conditionally observes on different schedulers. + /// + /// The downstream observer. + /// The scheduler to use when condition is true. + /// The scheduler to use when condition is false. + private sealed class ObserveOnIfSink( + IObserver downstream, + ISequencer trueScheduler, + ISequencer falseScheduler) : IObserver, IDisposable + { + /// + /// The gate for synchronization. + /// + private readonly Lock _gate = new(); + + /// + /// The downstream observer. + /// + private readonly IObserver _downstream = InvalidOperationExceptionHelper.Check(downstream); + + /// + /// The scheduler to use when condition is true. + /// + private readonly ISequencer _trueScheduler = InvalidOperationExceptionHelper.Check(trueScheduler); + + /// + /// The scheduler to use when condition is false. + /// + private readonly ISequencer _falseScheduler = InvalidOperationExceptionHelper.Check(falseScheduler); + + /// + /// The current scheduler. + /// + private ISequencer _currentScheduler = falseScheduler; + + /// + /// The last condition value. + /// + private bool _lastCondition; + + /// + /// Whether the condition has been received. + /// + private bool _hasCondition; + + /// + /// Whether the sequence is done. + /// + private bool _done; + + /// + /// Gets the condition observer. + /// + public IObserver ConditionObserver => new ConditionObserverImpl(this); + + /// + public void OnNext(T value) + { + ISequencer scheduler; + lock (_gate) + { + if (_done) + { + return; + } + + scheduler = _currentScheduler; + } + + scheduler.Schedule((Sink: this, Value: value), static state => state.Sink.ForwardNext(state.Value)); + } + + /// + public void OnError(Exception error) + { + lock (_gate) + { + if (_done) + { + return; + } + + _done = true; + } + + _downstream.OnError(error); + } + + /// + public void OnCompleted() + { + lock (_gate) + { + if (_done) + { + return; + } + + _done = true; + } + + _downstream.OnCompleted(); + } + + /// + public void Dispose() + { + lock (_gate) + { + _done = true; + } + } + + /// Updates the scheduler selected by the condition stream. + /// The latest condition value. + private void UpdateCondition(bool conditionValue) + { + lock (_gate) + { + if (_hasCondition && _lastCondition == conditionValue) + { + return; + } + + _currentScheduler = conditionValue ? _trueScheduler : _falseScheduler; + _lastCondition = conditionValue; + _hasCondition = true; + } + } + + /// Forwards a scheduled value to the downstream observer if the sink is still active. + /// The value to forward. + private void ForwardNext(T value) + { + lock (_gate) + { + if (_done) + { + return; + } + } + + _downstream.OnNext(value); + } + + /// + /// Observer for condition updates. + /// + /// The owning sink. + private sealed class ConditionObserverImpl(ObserveOnIfSink sink) : IObserver + { + /// + public void OnNext(bool value) => sink.UpdateCondition(value); + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/ObserveOnObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/ObserveOnObservable.cs new file mode 100644 index 0000000..509d5cd --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/ObserveOnObservable.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 ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Marshals every source notification onto the supplied , preserving order. +/// Replaces the System.Reactive.Linq.Observable.ObserveOn delegation behind the sync +/// ObserveOnSafe / ObserveOnIf helpers with our own queue-and-single-drain marshaller: +/// notifications are enqueued and a single drain pass is scheduled per burst (rather than one +/// scheduled action per item). The shared queue / gate / drain machinery lives in +/// ; this sink only carries the forward-everything drain handling. +/// +/// The element type of the source sequence. +/// The source observable. +/// The scheduler every notification is delivered on. +internal sealed class ObserveOnObservable(IObservable source, ISequencer scheduler) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(scheduler); + ArgumentExceptionHelper.ThrowIfNull(observer); + + // The immediate scheduler runs scheduled work inline on the calling thread, so the + // queue-and-drain machinery would be pure overhead: forward straight through. + if (ReferenceEquals(scheduler, Sequencer.Immediate)) + { + return source.Subscribe(observer); + } + + var sink = new ObserveOnSink(observer, scheduler); + sink.AttachSourceSubscription(source.Subscribe(sink)); + return sink; + } + + /// + /// Single observer that queues upstream notifications and drains them on the scheduler thread in + /// FIFO order. Terminal notifications travel through the same queue so they never overtake + /// still-queued values. + /// + private sealed class ObserveOnSink : IObserver, IDisposable, IDrainTarget + { + /// The downstream observer. + private readonly IObserver _downstream; + + /// Shared queue / gate / scheduled-drain machinery. + private readonly ScheduledDrainState _state; + + /// Initializes a new instance of the class. + /// The downstream observer. + /// The scheduler notifications are delivered on. + public ObserveOnSink(IObserver downstream, ISequencer scheduler) + { + _downstream = downstream; + _state = new ScheduledDrainState(scheduler, this); + } + + /// Records the upstream subscription so can tear it down. + /// The upstream subscription handle. + public void AttachSourceSubscription(IDisposable subscription) => _state.Attach(subscription); + + /// + public void OnNext(T value) => _state.EnqueueNext(value); + + /// + public void OnError(Exception error) => _state.EnqueueError(error); + + /// + public void OnCompleted() => _state.EnqueueCompleted(); + + /// + public void Dispose() => _state.BeginDispose()?.Dispose(); + + /// + void IDrainTarget.Drain() + { + while (_state.TryDequeue(out var notification)) + { + switch (notification.Kind) + { + case DrainNotificationKind.Next: + { + _downstream.OnNext(notification.Value); + break; + } + + case DrainNotificationKind.Error: + { + _state.Terminate(); + _downstream.OnError(notification.Error!); + return; + } + + default: + { + // DrainNotificationKind has only three values; the discard arm absorbs Completed. + _state.Terminate(); + _downstream.OnCompleted(); + return; + } + } + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/PairwiseObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/PairwiseObservable.cs new file mode 100644 index 0000000..4c2a73a --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/PairwiseObservable.cs @@ -0,0 +1,67 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Emits (previous, current) pairs from a sequence. +/// +/// The type of elements in the source sequence. +/// The source observable. +internal sealed class PairwiseObservable(IObservable source) : IObservable<(T Previous, T Current)> +{ + /// + public IDisposable Subscribe(IObserver<(T Previous, T Current)> observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(observer); + + return source.Subscribe(new PairwiseObserver(observer)); + } + + /// + /// The observer for the pairwise operator. + /// + /// The downstream observer. + private sealed class PairwiseObserver(IObserver<(T Previous, T Current)> downstream) : IObserver + { + /// + /// The gate for state access. + /// + private readonly Lock _gate = new(); + + /// + /// The previous value. + /// + private T? _previous; + + /// + /// A value indicating whether there is a previous value. + /// + private bool _hasPrevious; + + /// + public void OnNext(T value) + { + lock (_gate) + { + if (_hasPrevious) + { + downstream.OnNext((_previous!, value)); + } + + _previous = value; + _hasPrevious = true; + } + } + + /// + public void OnError(Exception error) => downstream.OnError(error); + + /// + public void OnCompleted() => downstream.OnCompleted(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/PartitionObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/PartitionObservable.cs new file mode 100644 index 0000000..9bdcfa3 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/PartitionObservable.cs @@ -0,0 +1,314 @@ +// 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.Primitives.Extensions.Operators; + +using System; +using System.Threading; + +/// +/// Partitions a sequence into two observables based on a predicate. +/// +/// The type of elements in the source sequence. +internal sealed class PartitionObservable +{ + /// + /// The source observable. + /// + private readonly IObservable _source; + + /// + /// The predicate to partition elements. + /// + private readonly Func _predicate; + + /// + /// The gate for synchronization. + /// + private readonly Lock _gate = new(); + + /// + /// The source subscription. + /// + private IDisposable? _sourceSubscription; + + /// + /// The observer for the source. + /// + private PartitionSink? _sink; + + /// + /// The number of subscriptions. + /// + private int _subscriptionCount; + + /// + /// Initializes a new instance of the class. + /// + /// The source observable. + /// The predicate to partition elements. + public PartitionObservable(IObservable source, Func predicate) + { + _source = source; + _predicate = predicate; + True = new PartitionSide(this, true); + False = new PartitionSide(this, false); + } + + /// Gets the observable emitting elements that satisfy the predicate. + public IObservable True { get; } + + /// Gets the observable emitting elements that do not satisfy the predicate. + public IObservable False { get; } + + /// + /// Subscribes an observer to the specified side of the partition. + /// + /// The observer. + /// The side (true or false). + /// A disposable to unsubscribe. + private Subscription Subscribe(IObserver observer, bool side) + { + lock (_gate) + { + if (_subscriptionCount == 0) + { + _sink = new(this); + _sourceSubscription = _source.Subscribe(_sink); + } + + _subscriptionCount++; + _sink!.Add(observer, side); + } + + return new(this, observer, side); + } + + /// + /// Represents a subscription to the partition. + /// + /// The parent observable. + /// The observer. + /// The side. + private sealed class Subscription(PartitionObservable parent, IObserver observer, bool side) : IDisposable + { + /// + /// The parent observable. + /// + private readonly PartitionObservable _parent = parent; + + /// + /// The observer. + /// + private readonly IObserver _observer = observer; + + /// + /// The side. + /// + private readonly bool _side = side; + + /// + /// Whether the subscription is disposed. + /// + private int _disposed; + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + lock (_parent._gate) + { + // Invariant: a Subscription whose Interlocked.Exchange just transitioned _disposed + // from 0 to 1 was created by Subscribe under the same lock, which sets _sink + // before returning — so _sink is non-null here by construction. + _parent._sink!.Remove(_observer, _side); + _parent._subscriptionCount--; + if (_parent._subscriptionCount == 0) + { + // Subscribe set _sourceSubscription alongside _sink under the same lock, + // so when the last branch disposes here it is non-null by construction. + _parent._sourceSubscription!.Dispose(); + _parent._sourceSubscription = null; + _parent._sink = null; + } + } + } + } + + /// + /// Represents a side of the partition. + /// + private sealed class PartitionSide : IObservable + { + /// + /// The parent observable. + /// + private readonly PartitionObservable _parent; + + /// + /// The side. + /// + private readonly bool _side; + + /// + /// Initializes a new instance of the class. + /// + /// The parent observable. + /// The side (true or false). + public PartitionSide(PartitionObservable parent, bool side) + { + _parent = parent; + _side = side; + } + + /// + public IDisposable Subscribe(IObserver observer) => _parent.Subscribe(observer, _side); + } + + /// + /// Sink that partitions elements. + /// + /// The parent observable. + private sealed class PartitionSink(PartitionObservable parent) : IObserver + { + /// + /// The observers for the true side. + /// + private IObserver[] _trueObservers = []; + + /// + /// The observers for the false side. + /// + private IObserver[] _falseObservers = []; + + /// + /// Adds an observer to the specified side. + /// + /// The observer to add. + /// The side. + public void Add(IObserver observer, bool side) + { + if (side) + { + _trueObservers = [.. _trueObservers, observer]; + } + else + { + _falseObservers = [.. _falseObservers, observer]; + } + } + + /// + /// Removes an observer from the specified side. + /// + /// The observer to remove. + /// The side. + public void Remove(IObserver observer, bool side) + { + if (side) + { + var index = Array.IndexOf(_trueObservers, observer); + if (index >= 0) + { + if (_trueObservers.Length == 1) + { + _trueObservers = []; + } + else + { + var newObservers = new IObserver[_trueObservers.Length - 1]; + Array.Copy(_trueObservers, 0, newObservers, 0, index); + Array.Copy(_trueObservers, index + 1, newObservers, index, _trueObservers.Length - index - 1); + _trueObservers = newObservers; + } + } + } + else + { + var index = Array.IndexOf(_falseObservers, observer); + if (index >= 0) + { + if (_falseObservers.Length == 1) + { + _falseObservers = []; + } + else + { + var newObservers = new IObserver[_falseObservers.Length - 1]; + Array.Copy(_falseObservers, 0, newObservers, 0, index); + Array.Copy(_falseObservers, index + 1, newObservers, index, _falseObservers.Length - index - 1); + _falseObservers = newObservers; + } + } + } + } + + /// + public void OnCompleted() + { + IObserver[] trueObservers; + IObserver[] falseObservers; + + lock (parent._gate) + { + trueObservers = _trueObservers; + falseObservers = _falseObservers; + } + + for (var i = 0; i < trueObservers.Length; i++) + { + trueObservers[i].OnCompleted(); + } + + for (var i = 0; i < falseObservers.Length; i++) + { + falseObservers[i].OnCompleted(); + } + } + + /// + public void OnError(Exception error) + { + IObserver[] trueObservers; + IObserver[] falseObservers; + + lock (parent._gate) + { + trueObservers = _trueObservers; + falseObservers = _falseObservers; + } + + for (var i = 0; i < trueObservers.Length; i++) + { + trueObservers[i].OnError(error); + } + + for (var i = 0; i < falseObservers.Length; i++) + { + falseObservers[i].OnError(error); + } + } + + /// + public void OnNext(T value) + { + var result = parent._predicate(value); + + IObserver[] observers; + lock (parent._gate) + { + observers = result ? _trueObservers : _falseObservers; + } + + for (var i = 0; i < observers.Length; i++) + { + observers[i].OnNext(value); + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/PropertyChangedObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/PropertyChangedObservable.cs new file mode 100644 index 0000000..aafb1e5 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/PropertyChangedObservable.cs @@ -0,0 +1,136 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.ComponentModel; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Fused replacement for +/// Observable.FromEventPattern(...).Where(name match).Select(getter).StartWith(getter). +/// Subscribes directly to , holds the +/// compiled getter once at construction (the original chain compiled it twice per +/// subscription), and emits the current value on subscribe followed by the getter +/// result for each matching property change — all through a single sink. +/// +/// The owning type that raises . +/// The property element type. +/// The owning instance. +/// The property name to filter by. +/// The compiled property getter (compiled once at construction). +internal sealed class PropertyChangedObservable( + T source, + string propertyName, + Func getter) : IObservable + where T : INotifyPropertyChanged +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(propertyName); + InvalidOperationExceptionHelper.ThrowIfNull(getter); + ArgumentExceptionHelper.ThrowIfNull(observer); + + observer.OnNext(getter(source)); + + var sink = new PropertyChangedSink(observer, source, propertyName, getter); + source.PropertyChanged += sink.Handler; + return sink; + } + + /// + /// Sink that holds the bound and detaches + /// it on dispose. Filters by property name inline and pulls the value through the + /// pre-compiled getter. + /// + private sealed class PropertyChangedSink : IDisposable + { + /// The downstream observer receiving filtered property values. + private readonly IObserver _downstream; + + /// The owning instance whose event is being observed. + private readonly T _source; + + /// The property name to filter on. + private readonly string _propertyName; + + /// The pre-compiled property getter. + private readonly Func _getter; + + /// Disposed flag (0 = active, 1 = disposed). Updated lock-free. + private int _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The downstream observer. + /// The owning instance. + /// The property name to filter on. + /// The pre-compiled property getter. + public PropertyChangedSink( + IObserver downstream, + T source, + string propertyName, + Func getter) + { + _downstream = downstream; + _source = source; + _propertyName = propertyName; + _getter = getter; + Handler = OnPropertyChanged; + } + + /// + /// Gets the bound event handler attached to the source's + /// event. + /// + public PropertyChangedEventHandler Handler { get; } + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _source.PropertyChanged -= Handler; + } + + /// + /// Bound handler — filters + /// by property name then forwards the pre-compiled getter result downstream. + /// + /// Event sender (unused). + /// Event payload carrying the changed property name. + private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (Volatile.Read(ref _disposed) != 0) + { + return; + } + + if (e.PropertyName != _propertyName) + { + return; + } + + TProperty value; + try + { + value = _getter(_source); + } + catch (Exception ex) + { + _downstream.OnError(ex); + Dispose(); + return; + } + + _downstream.OnNext(value); + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/ReplayLastOnSubscribeObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/ReplayLastOnSubscribeObservable.cs new file mode 100644 index 0000000..7260b9f --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/ReplayLastOnSubscribeObservable.cs @@ -0,0 +1,29 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Emits a stored initial value to every new subscriber, then forwards subsequent emissions from the +/// shared source. Each subscriber gets its own independent subscription to the source; the per-subscriber +/// replay is the fixed initialValue supplied at construction (matching the legacy BehaviorSubject +/// semantics — late subscribers do NOT see the latest value emitted to earlier subscribers). +/// +/// The element type of the source observable. +/// The source observable. +/// The initial value emitted to every new subscriber. +internal sealed class ReplayLastOnSubscribeObservable(IObservable source, T initialValue) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(observer); + + observer.OnNext(initialValue); + return source.Subscribe(observer); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/RetryBackoffPolicy.cs b/src/ReactiveUI.Primitives.Extensions/Operators/RetryBackoffPolicy.cs new file mode 100644 index 0000000..938ed58 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/RetryBackoffPolicy.cs @@ -0,0 +1,26 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Bundled retry configuration for : retry count, +/// delay schedule, scheduler, and an optional error sink. A +/// so it stays allocation-free and keeps consuming constructors below Sonar's S107 parameter limit. +/// +/// Maximum number of retries. +/// Delay before the first retry. +/// Multiplier applied to the delay per retry attempt. +/// Cap on the computed delay, or for no cap. +/// Scheduler used to schedule each delay. +/// Optional callback invoked on every upstream error. +internal readonly record struct RetryBackoffPolicy( + int MaxRetries, + TimeSpan InitialDelay, + double BackoffFactor, + TimeSpan? MaxDelay, + ISequencer Scheduler, + Action? OnError); diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/RetryForeverObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/RetryForeverObservable.cs new file mode 100644 index 0000000..d980bf1 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/RetryForeverObservable.cs @@ -0,0 +1,86 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Re-subscribes to the source indefinitely on error. Forwards values verbatim and completes when +/// the source completes. Per-subscription state is held in a single sink; resubscription swaps the +/// inner disposable rather than allocating a new wrapper chain. +/// +/// Element type. +/// Upstream source. +internal sealed class RetryForeverObservable(IObservable source) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(observer); + var sink = new RetrySink(source, observer); + sink.Start(); + return sink; + } + + /// Sink that re-subscribes the source on every error. + private sealed class RetrySink(IObservable source, IObserver downstream) : IObserver, IDisposable + { + /// Holds the current inner subscription; swapped on each resubscribe. + private readonly MutableDisposable _inner = new(); + + /// Latches to 1 on the first dispose so teardown is idempotent. + private int _disposed; + + /// Begins the first subscription. + public void Start() => _inner.Disposable = source.Subscribe(this); + + /// + public void OnNext(T value) + { + if (Volatile.Read(ref _disposed) != 0) + { + return; + } + + downstream.OnNext(value); + } + + /// + public void OnError(Exception error) + { + if (Volatile.Read(ref _disposed) != 0) + { + return; + } + + _ = error; + _inner.Disposable = source.Subscribe(this); + } + + /// + public void OnCompleted() + { + if (Volatile.Read(ref _disposed) != 0) + { + return; + } + + downstream.OnCompleted(); + } + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _inner.Dispose(); + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/RetryWithBackoffObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/RetryWithBackoffObservable.cs new file mode 100644 index 0000000..bd1d279 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/RetryWithBackoffObservable.cs @@ -0,0 +1,127 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Retries the source observable sequence upon error, with optional delay, retry count, and backoff. +/// +/// The type of elements in the source sequence. +/// The source observable. +/// The retry / backoff configuration. +internal sealed class RetryWithBackoffObservable( + IObservable source, + RetryBackoffPolicy policy) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(policy.Scheduler); + ArgumentExceptionHelper.ThrowIfNull(observer); + + var sink = new RetryWithBackoffSink(observer, source, policy); + sink.Run(); + return sink; + } + + /// + /// Sink that manages retries with exponential backoff. + /// + /// The downstream observer. + /// The source observable. + /// The retry / backoff configuration. + private sealed class RetryWithBackoffSink( + IObserver downstream, + IObservable source, + RetryBackoffPolicy policy) : IObserver, IDisposable + { + /// The gate for state access. + private readonly Lock _gate = new(); + + /// The subscription to the source sequence. + private readonly MutableDisposable _subscription = new(); + + /// The number of retries already attempted. + private int _retries; + + /// Whether the sink has been disposed. + private bool _disposed; + + /// Starts the retry process. + public void Run() => SubscribeToSource(); + + /// + public void OnNext(T value) => downstream.OnNext(value); + + /// + public void OnError(Exception error) + { + policy.OnError?.Invoke(error); + + lock (_gate) + { + if (_disposed) + { + return; + } + + if (_retries < policy.MaxRetries) + { + var delay = TimeSpan.FromTicks((long)(policy.InitialDelay.Ticks * Math.Pow(policy.BackoffFactor, _retries))); + if (policy.MaxDelay.HasValue && delay > policy.MaxDelay.Value) + { + delay = policy.MaxDelay.Value; + } + + _retries++; + + if (delay == TimeSpan.Zero) + { + SubscribeToSource(); + } + else + { + _subscription.Disposable = policy.Scheduler.Schedule(delay, SubscribeToSource); + } + } + else + { + downstream.OnError(error); + } + } + } + + /// + public void OnCompleted() => downstream.OnCompleted(); + + /// + public void Dispose() + { + lock (_gate) + { + _disposed = true; + _subscription.Dispose(); + } + } + + /// Subscribes to the source sequence. + private void SubscribeToSource() + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _subscription.Disposable = source.Subscribe(this); + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/RetryWithDelayObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/RetryWithDelayObservable.cs new file mode 100644 index 0000000..3c428d0 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/RetryWithDelayObservable.cs @@ -0,0 +1,130 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Retries the source observable sequence upon error, with a delay selected by a function. +/// +/// The type of elements in the source sequence. +/// The source observable. +/// The maximum number of retries. +/// A function to select the delay for each retry attempt. +internal sealed class RetryWithDelayObservable( + IObservable source, + int retryCount, + Func delaySelector) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(delaySelector); + ArgumentExceptionHelper.ThrowIfNull(observer); + + var sink = new RetryWithDelaySink(observer, source, retryCount, delaySelector, Sequencer.Default); + sink.Run(); + return sink; + } + + /// + /// Sink that manages retries with a custom delay selector. + /// + /// The downstream observer. + /// The source observable. + /// The maximum number of retries. + /// The delay selector. + /// The scheduler used to time retry delays. + private sealed class RetryWithDelaySink( + IObserver downstream, + IObservable source, + int maxRetries, + Func delaySelector, + ISequencer scheduler) : IObserver, IDisposable + { + /// The gate for state access. + private readonly Lock _gate = new(); + + /// The subscription to the source sequence. + private readonly MutableDisposable _subscription = new(); + + /// The number of retries already attempted. + private int _retries; + + /// Whether the sink has been disposed. + private bool _disposed; + + /// Starts the retry process. + public void Run() => SubscribeToSource(); + + /// + public void OnNext(T value) => downstream.OnNext(value); + + /// + public void OnError(Exception error) + { + lock (_gate) + { + if (_disposed) + { + return; + } + + if (_retries < maxRetries) + { + var delay = delaySelector(_retries); + _retries++; + + if (delay == TimeSpan.Zero) + { + SubscribeToSource(); + } + else + { + _subscription.Disposable = scheduler.Schedule(this, delay, static (_, self) => + { + self.SubscribeToSource(); + return EmptyDisposable.Instance; + }); + } + } + else + { + downstream.OnError(error); + } + } + } + + /// + public void OnCompleted() => downstream.OnCompleted(); + + /// + public void Dispose() + { + lock (_gate) + { + _disposed = true; + _subscription.Dispose(); + } + } + + /// Subscribes to the source sequence. + private void SubscribeToSource() + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _subscription.Disposable = source.Subscribe(this); + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/RunAllObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/RunAllObservable.cs new file mode 100644 index 0000000..951399a --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/RunAllObservable.cs @@ -0,0 +1,162 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Runs a list of one-shot observables sequentially, +/// ignoring emitted values, and emits a single when all +/// have completed. If the list is empty, emits immediately. +/// Errors from any observable propagate to the downstream observer. +/// +/// +/// Replaces patterns like sources.Concat().LastOrDefaultAsync() with a single +/// operator that subscribes sequentially. Uses an iterative loop with a sync-completion +/// flag to avoid stack overflow when sources complete synchronously during +/// Subscribe. +/// +/// The list of one-shot observables to run in order. +internal sealed class RunAllObservable(IReadOnlyList> sources) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(sources); + ArgumentExceptionHelper.ThrowIfNull(observer); + if (sources.Count == 0) + { + observer.OnNext(RxVoid.Default); + observer.OnCompleted(); + return EmptyDisposable.Instance; + } + + var sink = new Sink(observer, sources); + sink.RunNext(); + return sink; + } + + /// + /// Stateful observer that walks the source list sequentially. The sink subscribes itself + /// directly to each source — its own sets a + /// per-iteration flag the surrounding loop reads to decide whether to advance. This + /// replaces the previous probe-observer-per-iteration allocation pattern. + /// + /// The downstream observer. + /// The source list to walk. + private sealed class Sink( + IObserver downstream, + IReadOnlyList> sources) : IObserver, IDisposable + { + /// Index of the current source being observed. + private int _index; + + /// Subscription to the current source. + private IDisposable? _currentSubscription; + + /// Set once all sources have completed or we've been disposed. + private bool _done; + + /// Guards against re-entrant calls. + private bool _looping; + + /// Per-iteration latch (0 = pending, 1 = terminated). Set by + /// when a source terminates synchronously during Subscribe; read by the surrounding + /// loop in . Accessed via so it crosses the + /// method boundary safely without needing a separate probe-observer allocation per iteration. + private int _iterationTerminated; + + /// + public void OnNext(RxVoid value) + { + // Ignore — we only care about completion. + } + + /// + public void OnError(Exception error) + { + if (_done) + { + return; + } + + _done = true; + downstream.OnError(error); + } + + /// + public void OnCompleted() + { + if (_done) + { + return; + } + + if (_looping) + { + // Inside the loop the surrounding RunNext reads _iterationTerminated; no recursion. + Volatile.Write(ref _iterationTerminated, 1); + return; + } + + RunNext(); + } + + /// + public void Dispose() + { + _done = true; + Interlocked.Exchange(ref _currentSubscription, null)?.Dispose(); + } + + /// + /// Subscribes to the next source, or emits RxVoid and completes if all are done. + /// Iteratively loops on synchronous completion to avoid recursive stack growth. + /// + internal void RunNext() + { + _looping = true; + try + { + while (!_done && _index < sources.Count) + { + var source = sources[_index++]; + Volatile.Write(ref _iterationTerminated, 0); + var sub = source.Subscribe(this); + Interlocked.Exchange(ref _currentSubscription, sub); + + if (Volatile.Read(ref _iterationTerminated) == 0) + { + return; + } + } + } + finally + { + _looping = false; + } + + CompleteRun(); + } + + /// Emits the terminal and completes once all sources have run. + /// The already-done early-out is only reachable when a concurrent dispose latches between the + /// loop exit and this call; this small completion shell is excluded from coverage as race-only while the + /// trampoline loop in stays covered. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private void CompleteRun() + { + if (_done) + { + return; + } + + _done = true; + downstream.OnNext(RxVoid.Default); + downstream.OnCompleted(); + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/SampleLatestObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/SampleLatestObservable.cs new file mode 100644 index 0000000..a419928 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/SampleLatestObservable.cs @@ -0,0 +1,174 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Samples the latest value from the source observable whenever a trigger observable emits. +/// +/// The type of elements in the source sequence. +/// The source observable. +/// The trigger observable. +internal sealed class SampleLatestObservable( + IObservable source, + IObservable trigger) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(trigger); + ArgumentExceptionHelper.ThrowIfNull(observer); + + var sink = new SampleLatestSink(observer); + var sourceSub = source.Subscribe(sink.SourceObserver); + var triggerSub = trigger.Subscribe(sink.TriggerObserver); + return new DisposableBag(sourceSub, triggerSub, sink); + } + + /// + /// Sinks the source observable and samples it based on the trigger. + /// + /// The downstream observer. + private sealed class SampleLatestSink(IObserver downstream) : IDisposable + { + /// + /// The gate for synchronization. + /// + private readonly Lock _gate = new(); + + /// + /// The latest value from the source. + /// + private T? _latest; + + /// + /// Whether the source has produced a value. + /// + private bool _hasValue; + + /// + /// Whether the sequence is done. + /// + private bool _done; + + /// + /// Gets the source observer. + /// + public IObserver SourceObserver => new SourceSampleObserver(this); + + /// + /// Gets the trigger observer. + /// + public IObserver TriggerObserver => new TriggerSampleObserver(this); + + /// + public void Dispose() + { + lock (_gate) + { + _done = true; + } + } + + /// Records the latest source value. + /// The source value. + private void OnSourceNext(T value) + { + lock (_gate) + { + _latest = value; + _hasValue = true; + } + } + + /// Forwards a terminal error from either source. + /// The terminal error. + private void OnAnyError(Exception error) + { + lock (_gate) + { + if (_done) + { + return; + } + + _done = true; + downstream.OnError(error); + } + } + + /// Forwards source completion. + private void OnSourceCompleted() + { + lock (_gate) + { + if (_done) + { + return; + } + + _done = true; + downstream.OnCompleted(); + } + } + + /// Samples and forwards the latest source value if one is available. + private void OnTriggerNext() + { + T? value; + bool shouldEmit; + lock (_gate) + { + shouldEmit = _hasValue; + value = _latest; + } + + if (!shouldEmit) + { + return; + } + + downstream.OnNext(value!); + } + + /// + /// Observer for source values. + /// + /// The owning sink. + private sealed class SourceSampleObserver(SampleLatestSink sink) : IObserver + { + /// + public void OnNext(T value) => sink.OnSourceNext(value); + + /// + public void OnError(Exception error) => sink.OnAnyError(error); + + /// + public void OnCompleted() => sink.OnSourceCompleted(); + } + + /// + /// Observer for trigger values. + /// + /// The owning sink. + private sealed class TriggerSampleObserver(SampleLatestSink sink) : IObserver + { + /// + public void OnNext(object value) => sink.OnTriggerNext(); + + /// + public void OnError(Exception error) => sink.OnAnyError(error); + + /// + public void OnCompleted() + { + /* Trigger completion does not affect sample */ + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/ScanWithInitialObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/ScanWithInitialObservable.cs new file mode 100644 index 0000000..b60f0cd --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/ScanWithInitialObservable.cs @@ -0,0 +1,124 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions; + +/// +/// Scans the source sequence and emits the initial value immediately upon subscription. +/// +/// The type of elements in the source sequence. +/// The type of the accumulated value. +/// The source observable. +/// The initial accumulated value. +/// The accumulator function. +internal sealed class ScanWithInitialObservable( + IObservable source, + TAccumulate initial, + Func accumulator) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(accumulator); + ArgumentExceptionHelper.ThrowIfNull(observer); + + var sink = new ScanWithInitialSink(observer, initial, accumulator); + sink.Initialize(); + return source.Subscribe(sink); + } + + /// + /// Sink that implements the scan with initial logic. + /// + /// The observer to forward elements to. + /// The initial accumulated value. + /// The accumulator function. + private sealed class ScanWithInitialSink( + IObserver downstream, + TAccumulate initial, + Func accumulator) : IObserver + { + /// + /// The gate to synchronize access to the sink state. + /// + private readonly Lock _gate = new(); + + /// + /// The current accumulated value. + /// + private TAccumulate _current = initial; + + /// + /// Whether the sink has finished. + /// + private bool _done; + + /// + /// Initializes the sink by emitting the initial value. + /// + public void Initialize() => downstream.OnNext(_current); + + /// + public void OnNext(TSource value) + { + TAccumulate current; + lock (_gate) + { + if (_done) + { + return; + } + + try + { + _current = accumulator(_current, value); + current = _current; + } + catch (Exception ex) + { + _done = true; + downstream.OnError(ex); + return; + } + } + + downstream.OnNext(current); + } + + /// + public void OnError(Exception error) + { + lock (_gate) + { + if (_done) + { + return; + } + + _done = true; + } + + downstream.OnError(error); + } + + /// + public void OnCompleted() + { + lock (_gate) + { + if (_done) + { + return; + } + + _done = true; + } + + downstream.OnCompleted(); + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/ScheduleConfig.cs b/src/ReactiveUI.Primitives.Extensions/Operators/ScheduleConfig.cs new file mode 100644 index 0000000..8384355 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/ScheduleConfig.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 ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Bundled scheduling configuration shared by and +/// . Carrying these parameters as a single readonly record +/// struct keeps observable/observer constructor parameter counts low, avoids SA1117-style parameter +/// soup, and lets the compiler copy the configuration into capture-free scheduler lambdas. +/// +/// The element type emitted by the configured observable. +/// The scheduler on which each emission is dispatched. +/// true when a delay (relative or absolute) is configured. +/// true when is used; false when applies. +/// The relative delay before each emission. +/// The absolute time at which each emission fires. +/// Optional transform applied to the value before emission. +/// Optional side-effect invoked with the value before emission. +internal readonly record struct ScheduleConfig( + ISequencer Scheduler, + bool HasDelay, + bool UseAbsolute, + TimeSpan DueTime, + DateTimeOffset AbsoluteDueTime, + Func? Transform, + Action? Action) +{ + /// Creates a config with no delay, no transform, no action. + /// The scheduler used to dispatch emissions. + /// A new configuration. + public static ScheduleConfig Immediate(ISequencer scheduler) => + new(scheduler, false, false, TimeSpan.Zero, default, null, null); + + /// Creates a config with a relative delay. + /// The scheduler used to dispatch emissions. + /// The relative delay before each emission. + /// A new configuration. + public static ScheduleConfig Delayed(ISequencer scheduler, TimeSpan dueTime) => + new(scheduler, true, false, dueTime, default, null, null); + + /// Creates a config with an absolute due time. + /// The scheduler used to dispatch emissions. + /// The absolute time at which each emission fires. + /// A new configuration. + public static ScheduleConfig Absolute(ISequencer scheduler, DateTimeOffset absoluteDueTime) => + new(scheduler, true, true, TimeSpan.Zero, absoluteDueTime, null, null); + + /// Returns a new config with the supplied transform applied to each value before emission. + /// The transform. + /// A new configuration. + public ScheduleConfig WithTransform(Func transform) => this with { Transform = transform }; + + /// Returns a new config with the supplied side-effect invoked with each value before emission. + /// The action. + /// A new configuration. + public ScheduleConfig WithAction(Action action) => this with { Action = action }; +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/ScheduledSourceObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/ScheduledSourceObservable.cs new file mode 100644 index 0000000..23657f3 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/ScheduledSourceObservable.cs @@ -0,0 +1,148 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Source-driven scheduled observable. Subscribes to an upstream +/// and, for every emitted value, schedules a +/// callback on the supplied that applies an optional +/// side-effect and/or transform +/// before forwarding the value to the downstream observer. Replaces the +/// Observable.Create<T>(o => source.Subscribe(v => scheduler.Schedule(...))) +/// family of source-driven Schedule overloads. +/// +/// The element type of the source observable. +/// +/// To match the original source.Subscribe(Action<T>) semantics, this +/// operator only forwards . Source errors and +/// completion are intentionally not propagated to the downstream observer; that +/// preserves the historical behaviour of Observable.Create + a +/// next-only subscription. +/// +internal sealed class ScheduledSourceObservable : IObservable +{ + /// The upstream observable. + private readonly IObservable _source; + + /// The bundled scheduling configuration shared across every subscription. + private readonly ScheduleConfig _config; + + /// + /// Initializes a new instance of the class. + /// + /// The upstream observable. + /// Bundled scheduling configuration (scheduler, optional delay, optional transform/action). + public ScheduledSourceObservable(IObservable source, ScheduleConfig config) + { + ArgumentExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(config.Scheduler); + _source = source; + _config = config; + } + + /// + public IDisposable Subscribe(IObserver observer) + { + ArgumentExceptionHelper.ThrowIfNull(observer); + + var sink = new ScheduledSourceObserver(observer, _config); + return _source.Subscribe(sink); + } + + /// + /// Carries the per-emission state into the scheduled callback so the + /// scheduler lambda does not capture any fields. A + /// so it rides inside the + /// scheduler's work item by value rather than as a separate per-emission heap + /// allocation. + /// + /// The downstream observer. + /// The value to emit. + /// The optional transform. + /// The optional side-effect. + private readonly record struct EmitState( + IObserver Observer, + T Value, + Func? Transform, + Action? Action) + { + /// + /// Applies the optional side-effect and transform, then emits the value + /// to the captured observer. + /// + public void Emit() + { + try + { + Action?.Invoke(Value); + var emitted = Transform is null ? Value : Transform(Value); + Observer.OnNext(emitted); + } + catch (Exception error) + { + Observer.OnError(error); + } + } + } + + /// + /// Per-value sink that captures the configured scheduling parameters once + /// and schedules each through the configured + /// . + /// + private sealed class ScheduledSourceObserver(IObserver downstream, ScheduleConfig config) : IObserver + { + /// + public void OnNext(T value) + { + var state = new EmitState(downstream, value, config.Transform, config.Action); + var scheduler = config.Scheduler; + + if (!config.HasDelay) + { + scheduler.Schedule(state, static (_, s) => + { + s.Emit(); + return EmptyDisposable.Instance; + }); + return; + } + + if (config.UseAbsolute) + { + scheduler.Schedule(state, config.AbsoluteDueTime, static (_, s) => + { + s.Emit(); + return EmptyDisposable.Instance; + }); + return; + } + + scheduler.Schedule(state, config.DueTime, static (_, s) => + { + s.Emit(); + return EmptyDisposable.Instance; + }); + } + + /// + public void OnError(Exception error) + { + // Intentionally not forwarded: original Observable.Create + Subscribe(Action) + // pattern silently dropped source errors. Preserving that behaviour. + } + + /// + public void OnCompleted() + { + // Intentionally not forwarded: original Observable.Create + Subscribe(Action) + // pattern silently dropped completion. Preserving that behaviour. + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/ScheduledValueObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/ScheduledValueObservable.cs new file mode 100644 index 0000000..da5448d --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/ScheduledValueObservable.cs @@ -0,0 +1,152 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Single-value scheduled observable. On subscription, schedules a callback on +/// the supplied that applies an optional +/// side-effect and/or an optional +/// transform to the captured value, calls +/// once, then . Replaces the +/// Observable.Create<T>(o => scheduler.Schedule[Safe](due, () => o.OnNext(...))) +/// family of single-value Schedule overloads with one dedicated type +/// that captures only the fields each overload actually uses. +/// +/// The value type emitted to the downstream observer. +internal sealed class ScheduledValueObservable : IObservable +{ + /// The value to emit. + private readonly T _value; + + /// The scheduler used to dispatch the emission. + private readonly ISequencer _scheduler; + + /// The relative delay if is false. + private readonly TimeSpan _dueTime; + + /// The absolute due time if is true. + private readonly DateTimeOffset _absoluteDueTime; + + /// Optional transform applied to the value before emission. + private readonly Func? _transform; + + /// Optional side-effect invoked with the value before emission. + private readonly Action? _action; + + /// true when is used; otherwise is used. + private readonly bool _useAbsolute; + + /// true when a delay is configured (relative or absolute). + private readonly bool _hasDelay; + + /// + /// Initializes a new instance of the class. + /// + /// The value to emit. + /// The scheduler used to dispatch the emission. + /// Optional relative delay before emission. + /// Optional absolute time at which to emit. + /// Optional transform applied to the value before emission. + /// Optional side-effect invoked with the value before emission. + public ScheduledValueObservable( + T value, + ISequencer scheduler, + TimeSpan? dueTime, + DateTimeOffset? absoluteDueTime, + Func? transform, + Action? action) + { + ArgumentExceptionHelper.ThrowIfNull(scheduler); + _value = value; + _scheduler = scheduler; + _transform = transform; + _action = action; + + if (absoluteDueTime.HasValue) + { + _absoluteDueTime = absoluteDueTime.Value; + _useAbsolute = true; + _hasDelay = true; + } + else if (dueTime.HasValue) + { + _dueTime = dueTime.Value; + _hasDelay = true; + } + } + + /// + public IDisposable Subscribe(IObserver observer) + { + ArgumentExceptionHelper.ThrowIfNull(observer); + + var state = new EmitState(observer, _value, _transform, _action); + + if (!_hasDelay) + { + return _scheduler.Schedule(state, static (_, s) => + { + s.Emit(); + return EmptyDisposable.Instance; + }); + } + + if (_useAbsolute) + { + return _scheduler.Schedule(state, _absoluteDueTime, static (_, s) => + { + s.Emit(); + return EmptyDisposable.Instance; + }); + } + + return _scheduler.Schedule(state, _dueTime, static (_, s) => + { + s.Emit(); + return EmptyDisposable.Instance; + }); + } + + /// + /// Carries the per-subscription state into the scheduled callback so the + /// scheduler lambda does not capture any fields. + /// + /// The downstream observer. + /// The value to emit. + /// The optional transform. + /// The optional side-effect. + private sealed class EmitState( + IObserver observer, + T value, + Func? transform, + Action? action) + { + /// + /// Applies the optional side-effect and transform, then emits the value + /// followed by completion to the captured observer. + /// + public void Emit() + { + // Preserves the original Observable.Create-based semantics: the + // scheduled callback only emits OnNext. The sequence completes + // when downstream subscribers dispose; we do not auto-call + // OnCompleted here. + try + { + action?.Invoke(value); + var emitted = transform is null ? value : transform(value); + observer.OnNext(emitted); + } + catch (Exception error) + { + observer.OnError(error); + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/SelectAsyncConcurrentObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/SelectAsyncConcurrentObservable.cs new file mode 100644 index 0000000..e589709 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/SelectAsyncConcurrentObservable.cs @@ -0,0 +1,176 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Projects each element to an asynchronous operation with limited concurrency. +/// +/// The type of elements in the source sequence. +/// The type of the result of the asynchronous operation. +/// The source observable. +/// The asynchronous projection function. +/// The maximum number of concurrent operations. +internal sealed class SelectAsyncConcurrentObservable( + IObservable source, + Func> selector, + int maxConcurrency) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(selector); + ArgumentExceptionHelper.ThrowIfNull(observer); + + var sink = new SelectAsyncConcurrentSink(observer, selector, maxConcurrency); + var sub = source.Subscribe(sink); + return new DisposableBag(sub, sink); + } + + /// + /// Sink that manages concurrent async projection. + /// + /// The downstream observer. + /// The async selector. + /// The maximum concurrency. + private sealed class SelectAsyncConcurrentSink( + IObserver downstream, + Func> selector, + int maxConcurrency) : IObserver, IDisposable + { + /// The gate for state access. + private readonly Lock _gate = new(); + + /// Queue of values to process. + private readonly Queue _queue = new(); + + /// The number of currently running async operations. + private int _running; + + /// Whether the source has completed. + private bool _done; + + /// Whether the sink has been disposed. + private bool _disposed; + + /// + public void OnNext(TSource value) + { + lock (_gate) + { + if (_done || _disposed) + { + return; + } + + _queue.Enqueue(value); + TryProcessNext(); + } + } + + /// + public void OnError(Exception error) + { + lock (_gate) + { + if (_done || _disposed) + { + return; + } + + _done = true; + downstream.OnError(error); + } + } + + /// + public void OnCompleted() + { + lock (_gate) + { + if (_done || _disposed) + { + return; + } + + _done = true; + if (_running == 0 && _queue.Count == 0) + { + downstream.OnCompleted(); + } + } + } + + /// + public void Dispose() + { + lock (_gate) + { + _disposed = true; + } + } + + /// Attempts to process the next value in the queue. + private void TryProcessNext() + { + while (_running < maxConcurrency && _queue.Count > 0) + { + var value = _queue.Dequeue(); + _running++; + _ = ProcessAsync(value); + } + } + + /// Processes the async operation. + /// The value to project. + /// A task representing the operation. + private async Task ProcessAsync(TSource value) + { + try + { + var result = await selector(value).ConfigureAwait(false); + lock (_gate) + { + if (!_disposed) + { + downstream.OnNext(result); + } + } + } + catch (Exception ex) + { + lock (_gate) + { + if (!_disposed) + { + _done = true; + downstream.OnError(ex); + } + } + } + finally + { + lock (_gate) + { + _running--; + if (!_disposed) + { + if (_done && _running == 0 && _queue.Count == 0) + { + downstream.OnCompleted(); + } + else + { + TryProcessNext(); + } + } + } + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/SelectAsyncSequentialObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/SelectAsyncSequentialObservable.cs new file mode 100644 index 0000000..921cda2 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/SelectAsyncSequentialObservable.cs @@ -0,0 +1,169 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Projects each element to an asynchronous operation, preserving order and handling sequential execution. +/// +/// The type of elements in the source sequence. +/// The type of the result of the asynchronous operation. +/// The source observable. +/// The asynchronous projection function. +internal sealed class SelectAsyncSequentialObservable( + IObservable source, + Func> selector) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(selector); + ArgumentExceptionHelper.ThrowIfNull(observer); + + var sink = new SelectAsyncSequentialSink(observer, selector); + var sub = source.Subscribe(sink); + return new DisposableBag(sub, sink); + } + + /// + /// Sink that manages sequential async projection. + /// + /// The downstream observer. + /// The async selector. + private sealed class SelectAsyncSequentialSink( + IObserver downstream, + Func> selector) : IObserver, IDisposable + { + /// The gate for state access. + private readonly Lock _gate = new(); + + /// Queue of values to process. + private readonly Queue _queue = new(); + + /// Whether an async operation is currently in progress. + private bool _isProcessing; + + /// Whether the source has completed. + private bool _done; + + /// Whether the sink has been disposed. + private bool _disposed; + + /// + public void OnNext(TSource value) + { + lock (_gate) + { + if (_done || _disposed) + { + return; + } + + _queue.Enqueue(value); + if (!_isProcessing) + { + _isProcessing = true; + _ = ProcessNextAsync(); + } + } + } + + /// + public void OnError(Exception error) + { + lock (_gate) + { + if (_done || _disposed) + { + return; + } + + _done = true; + downstream.OnError(error); + } + } + + /// + public void OnCompleted() + { + lock (_gate) + { + if (_done || _disposed) + { + return; + } + + _done = true; + if (!_isProcessing) + { + downstream.OnCompleted(); + } + } + } + + /// + public void Dispose() + { + lock (_gate) + { + _disposed = true; + } + } + + /// Processes the next value in the queue. + /// A task representing the operation. + private async Task ProcessNextAsync() + { + while (true) + { + TSource value; + lock (_gate) + { + if (_disposed || _queue.Count == 0) + { + _isProcessing = false; + if (_done && !_disposed) + { + downstream.OnCompleted(); + } + + return; + } + + value = _queue.Dequeue(); + } + + try + { + var result = await selector(value).ConfigureAwait(false); + lock (_gate) + { + if (!_disposed) + { + downstream.OnNext(result); + } + } + } + catch (Exception ex) + { + lock (_gate) + { + if (!_disposed) + { + _done = true; + downstream.OnError(ex); + } + + _isProcessing = false; + return; + } + } + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/SelectConstantObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/SelectConstantObservable.cs new file mode 100644 index 0000000..48f55fc --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/SelectConstantObservable.cs @@ -0,0 +1,48 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Projection operator that ignores every source element and emits a stored constant +/// instead. Replaces the common .Select(_ => value) pattern, avoiding the +/// per-subscription closure allocation that the lambda _ => value would +/// capture. +/// +/// The source element type (ignored). +/// The result element type emitted to the downstream observer. +/// The source observable whose values are ignored. +/// The constant value emitted for each source element. +internal sealed class SelectConstantObservable( + IObservable source, + TResult constant) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(observer); + return source.Subscribe(new SelectConstantObserver(observer, constant)); + } + + /// + /// Forwarding observer that replaces every value with + /// the stored constant. Error and completion signals pass through unchanged. + /// + /// The downstream observer. + /// The constant value to emit. + private sealed class SelectConstantObserver(IObserver downstream, TResult constant) : IObserver + { + /// + public void OnNext(TSource value) => downstream.OnNext(constant); + + /// + public void OnError(Exception error) => downstream.OnError(error); + + /// + public void OnCompleted() => downstream.OnCompleted(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/SelectLatestAsyncObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/SelectLatestAsyncObservable.cs new file mode 100644 index 0000000..c0c0f2b --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/SelectLatestAsyncObservable.cs @@ -0,0 +1,185 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Projects each element to an asynchronous operation, but only the result of the latest operation is emitted. +/// +/// The type of elements in the source sequence. +/// The type of the result of the asynchronous operation. +/// The source observable. +/// The asynchronous projection function. +internal sealed class SelectLatestAsyncObservable( + IObservable source, + Func> selector) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(selector); + ArgumentExceptionHelper.ThrowIfNull(observer); + + var sink = new SelectLatestAsyncSink(observer, selector); + var sub = source.Subscribe(sink); + return new DisposableBag(sub, sink); + } + + /// + /// Sink that manages the latest async projection. + /// + /// The downstream observer. + /// The async selector. + private sealed class SelectLatestAsyncSink( + IObserver downstream, + Func> selector) : IObserver, IDisposable + { + /// The gate for state access. + private readonly Lock _gate = new(); + + /// The current operation ID to track latest. + private long _currentId; + + /// Whether the source has completed (no more values will arrive). + private bool _sourceCompleted; + + /// Whether downstream completion has been signalled. + private bool _completionSignalled; + + /// The latest in-flight projection task, used to delay completion until it finishes. + private Task? _latestTask; + + /// Whether the sink has been disposed. + private bool _disposed; + + /// + public void OnNext(TSource value) + { + long id; + lock (_gate) + { + if (_sourceCompleted || _disposed) + { + return; + } + + id = ++_currentId; + } + + var task = ProcessAsync(value, id); + lock (_gate) + { + _latestTask = task; + } + } + + /// + public void OnError(Exception error) + { + lock (_gate) + { + if (_sourceCompleted || _disposed) + { + return; + } + + _sourceCompleted = true; + _completionSignalled = true; + downstream.OnError(error); + } + } + + /// + public void OnCompleted() + { + Task? toAwait; + lock (_gate) + { + if (_sourceCompleted || _disposed) + { + return; + } + + _sourceCompleted = true; + toAwait = _latestTask; + } + + if (toAwait?.IsCompleted != false) + { + SignalCompleted(); + return; + } + + _ = toAwait.ContinueWith(static (_, s) => ((SelectLatestAsyncSink)s!).SignalCompleted(), this, TaskScheduler.Default); + } + + /// + public void Dispose() + { + lock (_gate) + { + _disposed = true; + } + } + + /// Signals downstream completion exactly once after the latest projection has finished. + private void SignalCompleted() + { + lock (_gate) + { + if (_disposed || _completionSignalled) + { + return; + } + + _completionSignalled = true; + downstream.OnCompleted(); + } + } + + /// Processes the async operation and checks for latest ID. + /// The value to project. + /// The ID of this operation. + /// A task representing the operation. + private async Task ProcessAsync(TSource value, long id) + { + try + { + var result = await selector(value).ConfigureAwait(false); + bool sourceDone; + lock (_gate) + { + if (_disposed || id != _currentId) + { + return; + } + + downstream.OnNext(result); + sourceDone = _sourceCompleted; + } + + if (sourceDone) + { + SignalCompleted(); + } + } + catch (Exception ex) + { + lock (_gate) + { + if (!_disposed && id == _currentId && !_completionSignalled) + { + _sourceCompleted = true; + _completionSignalled = true; + downstream.OnError(ex); + } + } + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/SelectManyThenObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/SelectManyThenObservable.cs new file mode 100644 index 0000000..2f161ed --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/SelectManyThenObservable.cs @@ -0,0 +1,112 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Fused .SelectMany(first).SelectMany(second) operator that chains two one-shot +/// async projections in a single operator allocation. The source emits a value, it's +/// projected through producing an intermediate observable, whose +/// single emission is then projected through producing the +/// final result. Errors at any stage propagate to the downstream observer. +/// +/// The source element type. +/// The intermediate element type produced by the first projection. +/// The final element type produced by the second projection. +/// The source observable. +/// First projection: source element → intermediate observable. +/// Second projection: intermediate element → result observable. +internal sealed class SelectManyThenObservable( + IObservable source, + Func> first, + Func> second) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(first); + InvalidOperationExceptionHelper.ThrowIfNull(second); + ArgumentExceptionHelper.ThrowIfNull(observer); + return source.Subscribe(new SourceObserver(observer, first, second)); + } + + /// Receives the source value and subscribes to the first projection. Holds a single + /// reusable created at subscribe time — the mid observer captures + /// only downstream and second, so the same instance handles every source emission. + private sealed class SourceObserver : IObserver + { + /// The downstream observer that ultimately receives values. + private readonly IObserver _downstream; + + /// First projection delegate. + private readonly Func> _first; + + /// Pre-allocated intermediate observer shared across every source emission. + private readonly MidObserver _midObserver; + + /// Initializes a new instance of the class and primes the reusable mid observer. + /// The downstream observer. + /// First projection delegate. + /// Second projection delegate. + public SourceObserver( + IObserver downstream, + Func> first, + Func> second) + { + _downstream = downstream; + _first = first; + _midObserver = new MidObserver(downstream, second); + } + + /// + public void OnNext(TSource value) + { + try + { + _first(value).Subscribe(_midObserver); + } + catch (Exception ex) + { + _downstream.OnError(ex); + } + } + + /// + public void OnError(Exception error) => _downstream.OnError(error); + + /// + public void OnCompleted() => _downstream.OnCompleted(); + } + + /// Receives the intermediate value, applies second, and subscribes the resulting + /// observable directly to downstream — no separate final-stage observer needed. + /// The downstream observer. + /// Second projection delegate. + private sealed class MidObserver( + IObserver downstream, + Func> second) : IObserver + { + /// + public void OnNext(TMid value) + { + try + { + second(value).Subscribe(downstream); + } + catch (Exception ex) + { + downstream.OnError(ex); + } + } + + /// + public void OnError(Exception error) => downstream.OnError(error); + + /// + public void OnCompleted() => downstream.OnCompleted(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/ShuffleObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/ShuffleObservable.cs new file mode 100644 index 0000000..2d30153 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/ShuffleObservable.cs @@ -0,0 +1,82 @@ +// 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; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Operator that randomly shuffles arrays emitted by the source. The shuffle is not +/// cryptographically secure — callers needing crypto-grade randomness should compose +/// themselves. +/// +/// The array element type. +/// The source observable emitting arrays. +internal sealed class ShuffleObservable(IObservable source) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(observer); + return source.Subscribe(new ShuffleObserver(observer)); + } + + /// Observer that shuffles arrays in place. + /// The downstream observer receiving shuffled arrays. + [SuppressMessage("Security", "CA5394:Do not use insecure randomness", Justification = "Shuffle is non-cryptographic; Random is faster.")] + private sealed class ShuffleObserver(IObserver downstream) : IObserver + { +#if !NET8_0_OR_GREATER + /// Per-thread used by the netfx fallback path. + [ThreadStatic] + private static Random? _threadRandom; +#endif + + /// + public void OnNext(T[] value) + { + if (value is null) + { + downstream.OnNext(value!); + return; + } + +#if NET8_0_OR_GREATER + Random.Shared.Shuffle(value); +#else + ShuffleInPlace(value); +#endif + + downstream.OnNext(value); + } + + /// + public void OnError(Exception error) => downstream.OnError(error); + + /// + public void OnCompleted() => downstream.OnCompleted(); + +#if !NET8_0_OR_GREATER + /// Fisher-Yates over a per-thread for targets without Random.Shuffle. + /// The array to shuffle in place. + private static void ShuffleInPlace(T[] array) + { + var random = _threadRandom; + if (random is null) + { + random = new Random(); + _threadRandom = random; + } + + for (var n = array.Length - 1; n > 0; n--) + { + var k = random.Next(n + 1); + (array[n], array[k]) = (array[k], array[n]); + } + } +#endif + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/SkipWhileNullObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/SkipWhileNullObservable.cs new file mode 100644 index 0000000..3955558 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/SkipWhileNullObservable.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.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Filtering operator that drops leading values, then forwards every value +/// (including subsequent s) once the latch has opened. Replaces the previous +/// source.SkipWhile(x => x == null) composition that delegated to System.Reactive's +/// SkipWhile, eliminating the per-subscription closure allocation and the extra observer +/// layer the chain introduced. +/// +/// The element type of the source observable; must be a reference type so the +/// null check is meaningful. +/// The source observable. +internal sealed class SkipWhileNullObservable(IObservable source) : IObservable + where T : class +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(observer); + return source.Subscribe(new SkipWhileNullObserver(observer)); + } + + /// + /// Forwarding observer that swallows leading values until the first + /// non-null value, then becomes a transparent forwarder for the remainder of the sequence. + /// + /// The downstream observer. + private sealed class SkipWhileNullObserver(IObserver downstream) : IObserver + { + /// Latches to after the first non-null value has been forwarded. + private bool _gateOpen; + + /// + public void OnNext(T value) + { + if (!_gateOpen) + { + if (value is null) + { + return; + } + + _gateOpen = true; + } + + downstream.OnNext(value); + } + + /// + public void OnError(Exception error) => downstream.OnError(error); + + /// + public void OnCompleted() => downstream.OnCompleted(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/StartActionObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/StartActionObservable.cs new file mode 100644 index 0000000..94571e1 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/StartActionObservable.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 ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Operator that runs an action and completes immediately. +/// +/// The action to run. +/// An optional scheduler; null runs the action synchronously inline. +internal sealed class StartActionObservable(Action action, ISequencer? scheduler) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(action); + ArgumentExceptionHelper.ThrowIfNull(observer); + + if (scheduler is null) + { + Run(observer, action); + return EmptyDisposable.Instance; + } + + var capturedObserver = observer; + var capturedAction = action; + return scheduler.Schedule((capturedObserver, capturedAction), static (_, state) => + { + Run(state.capturedObserver, state.capturedAction); + return EmptyDisposable.Instance; + }); + } + + /// + /// Runs and signals + /// with followed by completion. Exceptions + /// thrown by the action are forwarded to . + /// + /// The downstream observer. + /// The action to invoke. + private static void Run(IObserver observer, Action action) + { + try + { + action(); + } + catch (Exception error) + { + observer.OnError(error); + return; + } + + observer.OnNext(RxVoid.Default); + observer.OnCompleted(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/StartFuncObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/StartFuncObservable.cs new file mode 100644 index 0000000..a2a1262 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/StartFuncObservable.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Runs the function once — inline when no scheduler is supplied, otherwise on the scheduler — +/// emits the result, then completes. +/// +/// Result type. +/// Function to run. +/// Optional scheduler; null runs inline. +internal sealed class StartFuncObservable( + Func function, + ISequencer? scheduler) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(function); + ArgumentExceptionHelper.ThrowIfNull(observer); + + if (scheduler is null) + { + Run(observer, function); + return EmptyDisposable.Instance; + } + + var capturedObserver = observer; + var capturedFunction = function; + return scheduler.Schedule((capturedObserver, capturedFunction), static (_, state) => + { + Run(state.capturedObserver, state.capturedFunction); + return EmptyDisposable.Instance; + }); + } + + /// + /// Runs and signals + /// with the result followed by completion. Exceptions thrown by the function + /// are forwarded to . + /// + /// The downstream observer. + /// The function to invoke. + private static void Run(IObserver observer, Func function) + { + TResult result; + try + { + result = function(); + } + catch (Exception error) + { + observer.OnError(error); + return; + } + + observer.OnNext(result); + observer.OnCompleted(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/SubscribeAsyncObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/SubscribeAsyncObservable.cs new file mode 100644 index 0000000..cc87504 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/SubscribeAsyncObservable.cs @@ -0,0 +1,168 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Subscribes to an observable sequence and executes an asynchronous handler for each element. +/// +/// The type of elements in the source sequence. +internal sealed class SubscribeAsyncObservable : IDisposable +{ + /// The gate for state access. + private readonly Lock _gate = new(); + + /// Queue of values to process. + private readonly Queue _queue = new(); + + /// The subscription to the source sequence. + private readonly IDisposable _subscription; + + /// The asynchronous element handler. + private readonly Func _onNext; + + /// The error handler. + private readonly Action? _onError; + + /// The completion handler. + private readonly Action? _onCompleted; + + /// Whether an async operation is currently in progress. + private bool _isProcessing; + + /// Whether the source has completed. + private bool _done; + + /// Whether the sink has been disposed. + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The source observable. + /// The asynchronous element handler. + /// The error handler. + /// The completion handler. + public SubscribeAsyncObservable( + IObservable source, + Func onNext, + Action? onError = null, + Action? onCompleted = null) + { + _onNext = onNext; + _onError = onError; + _onCompleted = onCompleted; + _subscription = source.SubscribeCallbacks(OnNext, OnError, OnCompleted); + } + + /// + public void Dispose() + { + lock (_gate) + { + _disposed = true; + _subscription.Dispose(); + } + } + + /// Called when a new value is emitted by the source. + /// The value emitted by the source. + private void OnNext(T value) + { + lock (_gate) + { + if (_done || _disposed) + { + return; + } + + _queue.Enqueue(value); + if (!_isProcessing) + { + _isProcessing = true; + _ = ProcessNextAsync(); + } + } + } + + /// Called when an error occurs in the source. + /// The error that occurred. + private void OnError(Exception error) + { + lock (_gate) + { + if (_done || _disposed) + { + return; + } + + _done = true; + _onError?.Invoke(error); + } + } + + /// Called when the source completes. + private void OnCompleted() + { + lock (_gate) + { + if (_done || _disposed) + { + return; + } + + _done = true; + if (!_isProcessing) + { + _onCompleted?.Invoke(); + } + } + } + + /// Processes the next value in the queue. + /// A representing the asynchronous operation. + private async Task ProcessNextAsync() + { + while (true) + { + T value; + lock (_gate) + { + if (_disposed || _queue.Count == 0) + { + _isProcessing = false; + if (_done && !_disposed) + { + _onCompleted?.Invoke(); + } + + return; + } + + value = _queue.Dequeue(); + } + + try + { + await _onNext(value).ConfigureAwait(false); + } + catch (Exception ex) + { + lock (_gate) + { + if (!_disposed) + { + _done = true; + _onError?.Invoke(ex); + } + + _isProcessing = false; + return; + } + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/SwitchIfEmptyObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/SwitchIfEmptyObservable.cs new file mode 100644 index 0000000..662cba4 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/SwitchIfEmptyObservable.cs @@ -0,0 +1,124 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Provides a fallback observable if the source sequence completes without emitting any elements. +/// +/// The type of elements in the source sequence. +/// The source observable. +/// The fallback observable. +internal sealed class SwitchIfEmptyObservable( + IObservable source, + IObservable fallback) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(fallback); + ArgumentExceptionHelper.ThrowIfNull(observer); + + var sink = new SwitchIfEmptySink(observer, fallback); + var sub = source.Subscribe(sink); + sink.SetSubscription(sub); + return sink; + } + + /// + /// The sink for the . + /// + /// The downstream observer. + /// The fallback observable. + private sealed class SwitchIfEmptySink( + IObserver downstream, + IObservable fallback) : IObserver, IDisposable + { + /// The gate for state access. + private readonly Lock _gate = new(); + + /// The current subscription. + private readonly MutableDisposable _subscription = new(); + + /// Whether the source has emitted a value. + private bool _hasValue; + + /// Whether the sink has completed or been disposed. + private bool _done; + + /// + /// Sets the subscription to the source observable. + /// + /// The subscription. + public void SetSubscription(IDisposable sub) => _subscription.Disposable = sub; + + /// + public void OnNext(T value) + { + lock (_gate) + { + if (_done) + { + return; + } + + _hasValue = true; + } + + downstream.OnNext(value); + } + + /// + public void OnError(Exception error) + { + lock (_gate) + { + if (_done) + { + return; + } + + _done = true; + downstream.OnError(error); + } + } + + /// + public void OnCompleted() + { + lock (_gate) + { + if (_done) + { + return; + } + + if (_hasValue) + { + _done = true; + downstream.OnCompleted(); + } + else + { + // Source was empty, switch to fallback + _subscription.Disposable = fallback.Subscribe(downstream); + } + } + } + + /// + public void Dispose() + { + lock (_gate) + { + _done = true; + _subscription.Dispose(); + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/SyncTimerObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/SyncTimerObservable.cs new file mode 100644 index 0000000..9f2f0bd --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/SyncTimerObservable.cs @@ -0,0 +1,145 @@ +// 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.Concurrent; + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Optimized operator that shares a single timer per (TimeSpan, ISequencer) key. +/// Replaces the manual ConcurrentDictionary<..., Lazy<SharedTimer>> shape with a stateful +/// ConcurrentDictionary.GetOrAdd overload that doesn't allocate a +/// or its factory delegate on the hot path. +/// +internal static class SyncTimerObservable +{ + /// The timer cache, keyed by (TimeSpan, ISequencer). + private static readonly ConcurrentDictionary<(TimeSpan TimeSpan, ISequencer Scheduler), SharedTimer> + _timerList = []; + + /// Static factory passed to ConcurrentDictionary.GetOrAdd; + /// avoids a per-call delegate allocation. + private static readonly Func<(TimeSpan TimeSpan, ISequencer Scheduler), SharedTimer> _create = + static key => new SharedTimer(key.TimeSpan, key.Scheduler); + + /// + /// Gets a shared timer for the specified period and scheduler. + /// + /// The period. + /// The scheduler. + /// A shared observable sequence of timer ticks. + public static IObservable Get(TimeSpan timeSpan, ISequencer scheduler) + { + ArgumentExceptionHelper.ThrowIfNull(scheduler); + + return _timerList.GetOrAdd((timeSpan, scheduler), _create); + } + + /// + /// A manual implementation of a connectable timer that minimizes allocations and unrolls Rx chains. + /// Tick uses a swap-on-write array so the read path is allocation-free + /// and lock-free; subscribe / unsubscribe takes the gate and publishes a fresh array. + /// + /// The period. + /// The scheduler. + private sealed class SharedTimer(TimeSpan timeSpan, ISequencer scheduler) : IObservable + { + /// Sentinel empty observer array, shared so unsubscribing the last observer doesn't allocate. + private static readonly IObserver[] _emptyObservers = []; + + /// The gate for subscribe/unsubscribe writes. + private readonly Lock _gate = new(); + + /// + /// Snapshot of currently active observers. Replaced (not mutated) on subscribe / unsubscribe under + /// . The tick path reads this via Volatile.Read with no lock and no + /// allocation. + /// + private IObserver[] _observers = _emptyObservers; + + /// The active timer subscription, or when no observers are attached. + private IDisposable? _timerSubscription; + + /// + public IDisposable Subscribe(IObserver observer) + { + ArgumentExceptionHelper.ThrowIfNull(observer); + + lock (_gate) + { + var current = _observers; + var copy = new IObserver[current.Length + 1]; + for (var i = 0; i < current.Length; i++) + { + copy[i] = current[i]; + } + + copy[current.Length] = observer; + Volatile.Write(ref _observers, copy); + + _timerSubscription ??= scheduler.SchedulePeriodic(TimeSpan.Zero, timeSpan, Tick); + } + + return new TimerSubscription(this, observer); + } + + /// 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. + private void Remove(IObserver observer) + { + lock (_gate) + { + // TimerSubscription.Dispose's Interlocked guard ensures Remove is called at most + // once per subscription, and each subscription's observer was placed in _observers + // under this same lock before the disposable was returned — so RemoveOrNull always + // locates the observer by construction. + var updated = ObserverArrayHelpers.RemoveOrNull(_observers, observer, _emptyObservers)!; + + Volatile.Write(ref _observers, updated); + if (ReferenceEquals(updated, _emptyObservers)) + { + // Subscribe sets _timerSubscription on first add, before the disposable is + // returned; if we reach the "all observers gone" branch, at least one Subscribe + // ran, so _timerSubscription is non-null by construction. + _timerSubscription!.Dispose(); + _timerSubscription = null; + } + } + } + + /// + /// Per-subscribe disposable. Holding (parent, observer) as fields instead of capturing them in + /// a lambda removes the per-subscribe closure allocation that would + /// have required. + /// + /// The owning timer. + /// The observer to remove on dispose. + private sealed class TimerSubscription(SharedTimer parent, IObserver observer) : IDisposable + { + /// 0 = active, 1 = disposed. + private int _disposed; + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) + { + return; + } + + parent.Remove(observer); + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/SynchronizeAsyncObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/SynchronizeAsyncObservable.cs new file mode 100644 index 0000000..e54222b --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/SynchronizeAsyncObservable.cs @@ -0,0 +1,174 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Wraps elements in a synchronization context that waits for a disposal signal before proceeding to the next element. +/// +/// The type of elements in the source sequence. +/// The source observable. +internal sealed class SynchronizeAsyncObservable(IObservable source) : IObservable<(T Value, IDisposable Sync)> +{ + /// + public IDisposable Subscribe(IObserver<(T Value, IDisposable Sync)> observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(observer); + + var sink = new SynchronizeAsyncSink(observer); + var sub = source.Subscribe(sink); + return new DisposableBag(sub, sink); + } + + /// + /// The sink for the . + /// + /// The downstream observer. + private sealed class SynchronizeAsyncSink(IObserver<(T Value, IDisposable Sync)> downstream) + : IObserver, IDisposable + { + /// The gate for state access. + private readonly Lock _gate = new(); + + /// Whether the sink has completed. + private bool _done; + + /// Whether the sink has been disposed. + private bool _disposed; + + /// + /// The value. + public void OnNext(T value) + { + lock (_gate) + { + if (_done || _disposed) + { + return; + } + } + + // Implementation note: The original used 'new Continuation().Lock(item, observer)'. + // This is complex and stateful, so we maintain that logic in a way that respects sequentiality. + _ = ProcessAsync(value); + } + + /// + public void OnError(Exception error) + { + lock (_gate) + { + if (_done || _disposed) + { + return; + } + + _done = true; + downstream.OnError(error); + } + } + + /// + public void OnCompleted() + { + lock (_gate) + { + if (_done || _disposed) + { + return; + } + + _done = true; + downstream.OnCompleted(); + } + } + + /// + public void Dispose() + { + lock (_gate) + { + _disposed = true; + } + } + + /// + /// Processes the value. Pushes (value, signal) downstream and waits for the consumer + /// to dispose the signal. The fast path (consumer disposes synchronously inside OnNext) + /// returns a completed task without allocating a state machine or ; + /// the slow path (consumer defers disposal) lazily promotes the signal to a TCS-backed gate. + /// + /// The value to process. + /// A representing the asynchronous operation. + private Task ProcessAsync(T value) + { + var signal = new SyncSignal(); + downstream.OnNext((value, signal)); + return signal.WaitForDisposeAsync(); + } + + /// + /// Per-emission gate: the downstream receives this handle as Sync. The producer + /// calls after pushing the value; synchronous disposal + /// short-circuits to with no TCS allocation. Late + /// (asynchronous) disposal lazily allocates a single . + /// + private sealed class SyncSignal : IDisposable + { + /// The lazily-created completion source; only allocated on the slow path. + private TaskCompletionSource? _tcs; + + /// Latches to 1 on the first dispose so signalling is idempotent. + private int _disposed; + + /// Returns the task the producer should await before completing the emission. + /// The producer calls this exactly once per signal, so the TCS is published with a plain + /// volatile write rather than a compare-exchange. + /// A completed task if the consumer already disposed; otherwise the lazily-allocated TCS task. + public Task WaitForDisposeAsync() + { + if (Volatile.Read(ref _disposed) == 1) + { + return Task.CompletedTask; + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Volatile.Write(ref _tcs, tcs); + CompleteIfDisposedRaced(tcs); + return tcs.Task; + } + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + Volatile.Read(ref _tcs)?.TrySetResult(); + } + + /// Self-completes the just-published TCS if a dispose raced ahead of the publish and could + /// not see it, so the producer's await never hangs. + /// The completion source published for this signal. + /// The set-result is only taken when a concurrent dispose latches between the publish and + /// this re-check; isolated here and excluded from coverage as race-only. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private void CompleteIfDisposedRaced(TaskCompletionSource tcs) + { + if (Volatile.Read(ref _disposed) != 1) + { + return; + } + + tcs.TrySetResult(); + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/TakeUntilInclusiveObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/TakeUntilInclusiveObservable.cs new file mode 100644 index 0000000..6e88e3f --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/TakeUntilInclusiveObservable.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 ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Takes elements from the source sequence until a predicate returns true for an element. +/// The element that satisfies the predicate is included in the sequence. +/// +/// The type of elements in the source sequence. +/// The source observable. +/// The predicate to determine when to stop taking elements. +internal sealed class TakeUntilInclusiveObservable( + IObservable source, + Func predicate) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(predicate); + ArgumentExceptionHelper.ThrowIfNull(observer); + + return source.Subscribe(new TakeUntilInclusiveObserver(observer, predicate)); + } + + /// + /// The observer for the . + /// + /// The downstream observer. + /// The predicate to determine when to stop taking elements. + private sealed class TakeUntilInclusiveObserver( + IObserver downstream, + Func predicate) : IObserver + { + /// The gate for state access. + private readonly Lock _gate = new(); + + /// Whether the observer is done. + private bool _done; + + /// + /// The value. + public void OnNext(T value) + { + lock (_gate) + { + if (_done) + { + return; + } + + bool isMatch; + try + { + isMatch = predicate(value); + } + catch (Exception ex) + { + _done = true; + downstream.OnError(ex); + return; + } + + downstream.OnNext(value); + + if (!isMatch) + { + return; + } + + _done = true; + downstream.OnCompleted(); + } + } + + /// + /// The error. + public void OnError(Exception error) + { + lock (_gate) + { + if (_done) + { + return; + } + + _done = true; + downstream.OnError(error); + } + } + + /// + public void OnCompleted() + { + lock (_gate) + { + if (_done) + { + return; + } + + _done = true; + downstream.OnCompleted(); + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/ThrottleDistinctObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/ThrottleDistinctObservable.cs new file mode 100644 index 0000000..bb7da82 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/ThrottleDistinctObservable.cs @@ -0,0 +1,122 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Throttles a sequence and ensures only distinct values are emitted. +/// +/// The type of elements in the source sequence. +/// The source observable. +/// The throttle duration. +/// The scheduler to use for timing. +internal sealed class ThrottleDistinctObservable( + IObservable source, + TimeSpan throttle, + ISequencer scheduler) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(scheduler); + ArgumentExceptionHelper.ThrowIfNull(observer); + + // Implementation of .DistinctUntilChanged().Throttle(throttle, scheduler).DistinctUntilChanged() + // But fused into a single sink to avoid multiple operator allocations and observer chains. + var sink = new ThrottleDistinctSink(observer, throttle, scheduler); + var subscription = source.Subscribe(sink); + return new DisposableBag(subscription, sink); + } + + /// + /// Sink that implements the throttle distinct logic. Composes + /// for the shared gate / timer / done-flag plumbing so this class only carries the throttle + /// and distinct-value tracking. + /// + /// The observer to forward elements to. + /// The throttle duration. + /// The scheduler to use for timing. + private sealed class ThrottleDistinctSink( + IObserver downstream, + TimeSpan throttle, + ISequencer scheduler) : IObserver, IDisposable + { + /// Shared gate / timer / done-flag plumbing. + private readonly TimerSinkState _state = new(downstream); + + /// The last emitted value. + private T? _lastEmitted; + + /// The last received value. + private T? _lastReceived; + + /// Whether a value has been received but not yet emitted. + private bool _hasLastReceived; + + /// Whether any value has been emitted yet. + private bool _hasLastEmitted; + + /// + public void OnNext(T value) + { + lock (_state.Gate) + { + if (_state.Done) + { + return; + } + + if (_hasLastEmitted && EqualityComparer.Default.Equals(value, _lastEmitted!)) + { + _hasLastReceived = false; + _state.Timer.Disposable = null; + return; + } + + _lastReceived = value; + _hasLastReceived = true; + _state.Timer.Disposable = scheduler.Schedule(throttle, Emit); + } + } + + /// + public void OnError(Exception error) => _state.HandleError(error); + + /// + public void OnCompleted() => _state.HandleCompleted(); + + /// + public void Dispose() => _state.HandleDispose(); + + /// Emits the last received value if it differs from the last emitted value. + /// Marked [ExcludeFromCodeCoverage] because the in-lock + /// race-loser branch (sink done or no buffered value) is only reachable when the + /// scheduled callback fires concurrently with Dispose / OnCompleted, which the + /// single-threaded test harness cannot trigger. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private void Emit() + { + T? toEmit; + lock (_state.Gate) + { + if (_state.Done || !_hasLastReceived) + { + return; + } + + toEmit = _lastReceived; + _lastEmitted = toEmit; + _hasLastEmitted = true; + _hasLastReceived = false; + } + + downstream.OnNext(toEmit!); + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/ThrottleFirstObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/ThrottleFirstObservable.cs new file mode 100644 index 0000000..ac39353 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/ThrottleFirstObservable.cs @@ -0,0 +1,122 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Throttles a sequence by only emitting the first element in each window. +/// +/// The type of elements in the source sequence. +/// The source observable. +/// The window duration. +/// The scheduler to use for timing. +internal sealed class ThrottleFirstObservable( + IObservable source, + TimeSpan window, + ISequencer scheduler) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(scheduler); + ArgumentExceptionHelper.ThrowIfNull(observer); + + return source.Subscribe(new ThrottleFirstObserver(observer, window, scheduler)); + } + + /// + /// Observer that implements the throttle first logic. + /// + /// The observer to forward elements to. + /// The window duration. + /// The scheduler to use for timing. + private sealed class ThrottleFirstObserver( + IObserver downstream, + TimeSpan window, + ISequencer scheduler) : IObserver + { + /// + /// The gate to synchronize access to the observer state. + /// + private readonly Lock _gate = new(); + + /// + /// The last time an element was emitted. + /// + private DateTimeOffset _last; + + /// + /// Whether at least one element has been emitted. + /// + private bool _hasLast; + + /// + /// Whether the observer has finished. + /// + private bool _done; + + /// + public void OnNext(T value) + { + var now = scheduler.Now; + bool emit; + + lock (_gate) + { + if (_done) + { + return; + } + + emit = !_hasLast || now - _last >= window; + if (emit) + { + _last = now; + _hasLast = true; + } + } + + if (!emit) + { + return; + } + + downstream.OnNext(value); + } + + /// + public void OnError(Exception error) + { + lock (_gate) + { + if (_done) + { + return; + } + + _done = true; + downstream.OnError(error); + } + } + + /// + public void OnCompleted() + { + lock (_gate) + { + if (_done) + { + return; + } + + _done = true; + downstream.OnCompleted(); + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/ThrottleObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/ThrottleObservable.cs new file mode 100644 index 0000000..b396856 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/ThrottleObservable.cs @@ -0,0 +1,175 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Classic throttle (debounce) operator. Emits a value only after +/// has elapsed without any new emission from the +/// source. Each new upstream OnNext cancels the pending emission and +/// schedules a new one. Provides the equivalent of Rx's +/// Observable.Throttle without depending on System.Reactive.Linq. +/// +/// The element type of the source observable. +/// The source observable. +/// The quiescence duration required before emission. +/// The scheduler used to time emissions. +internal sealed class ThrottleObservable( + IObservable source, + TimeSpan dueTime, + ISequencer scheduler) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(scheduler); + ArgumentExceptionHelper.ThrowIfNull(observer); + + var sink = new ThrottleSink(observer, dueTime, scheduler); + var subscription = source.Subscribe(sink); + return new DisposableBag(subscription, sink); + } + + /// + /// Sink that holds the most-recent value and a scheduled emission that + /// fires after the configured quiescence interval. + /// + /// The downstream observer. + /// The quiescence duration. + /// The scheduler used to time emissions. + private sealed class ThrottleSink( + IObserver downstream, + TimeSpan dueTime, + ISequencer scheduler) : IObserver, IDisposable + { + /// The synchronization gate. + private readonly Lock _gate = new(); + + /// The pending scheduled emission. + private readonly SwapDisposable _pending = new(); + + /// The most-recent value waiting to be emitted. + private T _latest = default!; + + /// Whether a value is currently pending emission. + private bool _hasValue; + + /// Monotonic id of the latest scheduled emission. + private long _emissionId; + + /// Whether the sequence is terminally done. + private bool _done; + + /// + public void OnNext(T value) + { + long id; + lock (_gate) + { + if (_done) + { + return; + } + + _latest = value; + _hasValue = true; + id = ++_emissionId; + } + + _pending.Disposable = scheduler.Schedule((this, id), dueTime, static (_, state) => + { + state.Item1.Emit(state.id); + return EmptyDisposable.Instance; + }); + } + + /// + public void OnError(Exception error) + { + lock (_gate) + { + if (_done) + { + return; + } + + _done = true; + } + + _pending.Dispose(); + downstream.OnError(error); + } + + /// + public void OnCompleted() + { + T pending; + bool flush; + lock (_gate) + { + if (_done) + { + return; + } + + _done = true; + flush = _hasValue; + pending = _latest; + _hasValue = false; + } + + _pending.Dispose(); + + if (flush) + { + downstream.OnNext(pending); + } + + downstream.OnCompleted(); + } + + /// + public void Dispose() + { + lock (_gate) + { + _done = true; + } + + _pending.Dispose(); + } + + /// + /// Emits the buffered value if it is still current (i.e. no newer + /// arrived after this emission was scheduled). + /// Marked [ExcludeFromCodeCoverage] because the in-lock + /// race-loser branch (sink done, emission superseded, value already drained) is only + /// reachable when the scheduled callback fires concurrently with Dispose / OnCompleted, + /// which the single-threaded test harness cannot trigger. + /// + /// The emission id this callback was scheduled for. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private void Emit(long id) + { + T value; + lock (_gate) + { + if (_done || id != _emissionId || !_hasValue) + { + return; + } + + value = _latest; + _hasValue = false; + } + + downstream.OnNext(value); + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/ThrottleUntilTrueObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/ThrottleUntilTrueObservable.cs new file mode 100644 index 0000000..8908ae5 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/ThrottleUntilTrueObservable.cs @@ -0,0 +1,169 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Throttles a sequence until a predicate becomes true for an element. +/// +/// The type of elements in the source sequence. +/// The source observable. +/// The throttle duration. +/// The predicate to determine if an element should be emitted immediately or throttled. +internal sealed class ThrottleUntilTrueObservable( + IObservable source, + TimeSpan throttle, + Func predicate) : IObservable +{ + /// + /// The source observable. + /// + private readonly IObservable _source = InvalidOperationExceptionHelper.Check(source); + + /// + /// The throttle duration. + /// + private readonly TimeSpan _throttle = throttle; + + /// + /// The predicate to determine if an element should be emitted immediately or throttled. + /// + private readonly Func _predicate = InvalidOperationExceptionHelper.Check(predicate); + + /// + public IDisposable Subscribe(IObserver observer) + { + ArgumentExceptionHelper.ThrowIfNull(observer); + + var sink = new ThrottleUntilTrueSink(observer, _throttle, _predicate, Sequencer.Default); + var subscription = _source.Subscribe(sink); + return new DisposableBag(subscription, sink); + } + + /// + /// Sinks the source observable and throttles elements until a predicate is true. + /// + /// The downstream observer. + /// The throttle duration. + /// The predicate. + /// The scheduler used to time throttled emissions. + private sealed class ThrottleUntilTrueSink( + IObserver downstream, + TimeSpan throttle, + Func predicate, + ISequencer scheduler) : IObserver, IDisposable + { + /// + /// The gate for synchronization. + /// + private readonly Lock _gate = new(); + + /// + /// The downstream observer. + /// + private readonly IObserver _downstream = downstream; + + /// + /// The throttle duration. + /// + private readonly TimeSpan _throttle = throttle; + + /// + /// The predicate. + /// + private readonly Func _predicate = predicate; + + /// + /// The timer for throttling. + /// + private readonly SwapDisposable _timer = new(); + + /// + /// Whether the sequence is done. + /// + private bool _done; + + /// + public void OnNext(T value) + { + lock (_gate) + { + if (_done) + { + return; + } + + if (_predicate(value)) + { + _timer.Disposable = null; + _downstream.OnNext(value); + } + else + { + _timer.Disposable = scheduler.Schedule( + (sink: this, value), + _throttle, + static (_, state) => + { + lock (state.sink._gate) + { + if (!state.sink._done) + { + state.sink._downstream.OnNext(state.value); + } + } + + return EmptyDisposable.Instance; + }); + } + } + } + + /// + public void OnError(Exception error) + { + lock (_gate) + { + if (_done) + { + return; + } + + _done = true; + _timer.Dispose(); + _downstream.OnError(error); + } + } + + /// + public void OnCompleted() + { + lock (_gate) + { + if (_done) + { + return; + } + + _done = true; + _timer.Dispose(); + _downstream.OnCompleted(); + } + } + + /// + public void Dispose() + { + lock (_gate) + { + _done = true; + _timer.Dispose(); + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/TrySelectObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/TrySelectObservable.cs new file mode 100644 index 0000000..3e6e2c5 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/TrySelectObservable.cs @@ -0,0 +1,69 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Fused .Select(selector).Where(x => x is not null).Select(x => x!) operator +/// that applies a transform and emits only non-null results. Replaces the 3-operator +/// null-filtering chain with a single operator + observer allocation. +/// +/// The source element type. +/// The projected element type. +/// The source observable. +/// Projection that may return for elements that should be skipped. +internal sealed class TrySelectObservable( + IObservable source, + Func selector) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(selector); + ArgumentExceptionHelper.ThrowIfNull(observer); + return source.Subscribe(new TrySelectObserver(observer, selector)); + } + + /// + /// Observer that applies the selector and only forwards non-null results. + /// Exceptions from the selector are routed to . + /// + /// The downstream observer. + /// The projection delegate. + private sealed class TrySelectObserver( + IObserver downstream, + Func selector) : IObserver + { + /// + public void OnNext(TIn value) + { + TOut? projected; + try + { + projected = selector(value); + } + catch (Exception ex) + { + downstream.OnError(ex); + return; + } + + if (projected is null) + { + return; + } + + downstream.OnNext(projected); + } + + /// + public void OnError(Exception error) => downstream.OnError(error); + + /// + public void OnCompleted() => downstream.OnCompleted(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/UsingActionObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/UsingActionObservable.cs new file mode 100644 index 0000000..c49bced --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/UsingActionObservable.cs @@ -0,0 +1,83 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Resource-bound factory observable that runs an optional +/// against a captured +/// resource, emits , completes, and finally disposes +/// the resource. Replaces the legacy +/// Observable.Using(() => obj, id => Observable.Start(...)) pattern. +/// +/// The disposable resource type. +/// The resource to use during the operation and dispose at the end. +/// An optional action invoked against the resource. +/// An optional scheduler; null runs the action synchronously inline. +internal sealed class UsingActionObservable( + T resource, + Action? action, + ISequencer? scheduler) : IObservable + where T : IDisposable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + ArgumentExceptionHelper.ThrowIfNull(observer); + + if (scheduler is null) + { + Run(observer, resource, action); + return EmptyDisposable.Instance; + } + + var capturedObserver = observer; + var capturedResource = resource; + var capturedAction = action; + return scheduler.Schedule( + (capturedObserver, capturedResource, capturedAction), + static (_, state) => + { + Run(state.capturedObserver, state.capturedResource, state.capturedAction); + return EmptyDisposable.Instance; + }); + } + + /// + /// Invokes the optional action against the resource, signals + /// and completion, then disposes the resource. + /// + /// The downstream observer. + /// The resource to use and dispose. + /// The optional action. + private static void Run(IObserver observer, T resource, Action? action) + { + try + { + action?.Invoke(resource); + } + catch (Exception error) + { + try + { + resource?.Dispose(); + } + catch + { + // Swallow secondary dispose failure; the primary exception is what callers care about. + } + + observer.OnError(error); + return; + } + + observer.OnNext(RxVoid.Default); + observer.OnCompleted(); + resource?.Dispose(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/UsingFuncObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/UsingFuncObservable.cs new file mode 100644 index 0000000..39008a7 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/UsingFuncObservable.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.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Resource-bound factory observable that runs a +/// against a captured resource, emits the result, +/// completes, and finally disposes the resource. Replaces the legacy +/// Observable.Using(() => obj, id => Observable.Start(() => func(id))) pattern. +/// +/// The disposable resource type. +/// The result type emitted to the downstream observer. +/// The resource to use during the operation and dispose at the end. +/// The function invoked against the resource to produce the emitted value. +/// An optional scheduler; null runs the function synchronously inline. +internal sealed class UsingFuncObservable( + T resource, + Func function, + ISequencer? scheduler) : IObservable + where T : IDisposable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(function); + ArgumentExceptionHelper.ThrowIfNull(observer); + + if (scheduler is null) + { + Run(observer, resource, function); + return EmptyDisposable.Instance; + } + + var capturedObserver = observer; + var capturedResource = resource; + var capturedFunction = function; + return scheduler.Schedule( + (capturedObserver, capturedResource, capturedFunction), + static (_, state) => + { + Run(state.capturedObserver, state.capturedResource, state.capturedFunction); + return EmptyDisposable.Instance; + }); + } + + /// + /// Invokes the function against the resource, signals the produced value + /// followed by completion, then disposes the resource. + /// + /// The downstream observer. + /// The resource to use and dispose. + /// The function invoked against the resource. + private static void Run(IObserver observer, T resource, Func function) + { + TResult result; + try + { + result = function(resource); + } + catch (Exception error) + { + try + { + resource?.Dispose(); + } + catch + { + // Swallow secondary dispose failure; the primary exception is what callers care about. + } + + observer.OnError(error); + return; + } + + observer.OnNext(result); + observer.OnCompleted(); + resource?.Dispose(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/WaitUntilObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/WaitUntilObservable.cs new file mode 100644 index 0000000..7e26f0b --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/WaitUntilObservable.cs @@ -0,0 +1,121 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Fused Where(predicate).Take(1). Subscribes a single observer to the source, +/// emits the first value satisfying followed by +/// , then disposes the source subscription — +/// avoiding the two intermediate observer wrappers that the equivalent Rx chain would +/// allocate per subscription. +/// +/// The element type of the source observable. +/// The source observable. +/// The match predicate; the first value returning is emitted. +internal sealed class WaitUntilObservable( + IObservable source, + Func predicate) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(predicate); + ArgumentExceptionHelper.ThrowIfNull(observer); + + var subscription = new OnceDisposable(); + var sink = new WaitUntilObserver(observer, predicate, subscription); + subscription.Disposable = source.Subscribe(sink); + return subscription; + } + + /// + /// Forwarding observer that filters by and completes + /// after the first match, disposing the upstream subscription handle. + /// + /// The downstream observer. + /// The match predicate. + /// The handle controlling the upstream subscription. + private sealed class WaitUntilObserver( + IObserver downstream, + Func predicate, + IDisposable subscription) : IObserver + { + /// The gate for state access. + private readonly Lock _gate = new(); + + /// Whether the observer is done. + private bool _done; + + /// + public void OnNext(T value) + { + lock (_gate) + { + if (_done) + { + return; + } + + bool isMatch; + try + { + isMatch = predicate(value); + } + catch (Exception ex) + { + _done = true; + downstream.OnError(ex); + subscription.Dispose(); + return; + } + + if (!isMatch) + { + return; + } + + _done = true; + downstream.OnNext(value); + downstream.OnCompleted(); + } + + subscription.Dispose(); + } + + /// + public void OnError(Exception error) + { + lock (_gate) + { + if (_done) + { + return; + } + + _done = true; + downstream.OnError(error); + } + } + + /// + public void OnCompleted() + { + lock (_gate) + { + if (_done) + { + return; + } + + _done = true; + downstream.OnCompleted(); + } + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/WhereFalseObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/WhereFalseObservable.cs new file mode 100644 index 0000000..548b113 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/WhereFalseObservable.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Filtering operator that forwards only false values from a boolean source. +/// Replaces the source.Where(b => !b) pattern with a dedicated forwarding +/// observer, avoiding the per-subscription closure allocation that the predicate +/// lambda would otherwise capture. +/// +/// The boolean source observable. +internal sealed class WhereFalseObservable(IObservable source) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(observer); + return source.Subscribe(new WhereFalseObserver(observer)); + } + + /// + /// Forwarding observer that drops true values and passes false + /// values, terminal error and completion through unchanged. + /// + /// The downstream observer. + private sealed class WhereFalseObserver(IObserver downstream) : IObserver + { + /// + public void OnNext(bool value) + { + if (value) + { + return; + } + + downstream.OnNext(false); + } + + /// + public void OnError(Exception error) => downstream.OnError(error); + + /// + public void OnCompleted() => downstream.OnCompleted(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/WhereIsNotNullObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/WhereIsNotNullObservable.cs new file mode 100644 index 0000000..797929b --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/WhereIsNotNullObservable.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Filtering operator that forwards only the non-null values of the source +/// sequence. Replaces the source.Where(x => x is not null) pattern, +/// avoiding the per-subscription closure allocation that the predicate lambda +/// would otherwise capture. +/// +/// The element type of the source observable. +/// The source observable whose null values are filtered out. +internal sealed class WhereIsNotNullObservable(IObservable source) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(observer); + return source.Subscribe(new WhereIsNotNullObserver(observer)); + } + + /// + /// Forwarding observer that suppresses null values and + /// passes and through unchanged. + /// + /// The downstream observer. + private sealed class WhereIsNotNullObserver(IObserver downstream) : IObserver + { + /// + public void OnNext(T value) + { + if (value is null) + { + return; + } + + downstream.OnNext(value); + } + + /// + public void OnError(Exception error) => downstream.OnError(error); + + /// + public void OnCompleted() => downstream.OnCompleted(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/WhereSelectObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/WhereSelectObservable.cs new file mode 100644 index 0000000..8423e7d --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/WhereSelectObservable.cs @@ -0,0 +1,76 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Fused Where(predicate).Select(selector) operator. Replaces the two-operator +/// Rx chain with a single observable + observer pair, saving the intermediate +/// Select operator allocation (and its ) per +/// subscription on hot paths. +/// +/// The source element type. +/// The projected element type after applying the selector. +/// The source observable to filter and project. +/// Predicate applied to each source element; only elements returning are forwarded through . +/// Projection applied to elements that pass . +internal sealed class WhereSelectObservable( + IObservable source, + Func predicate, + Func selector) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(predicate); + InvalidOperationExceptionHelper.ThrowIfNull(selector); + ArgumentExceptionHelper.ThrowIfNull(observer); + return source.Subscribe(new WhereSelectObserver(observer, predicate, selector)); + } + + /// + /// Forwarding observer that applies the predicate and selector inline on each + /// . Any exception thrown by either delegate is routed to + /// on the downstream observer. + /// + /// The downstream observer receiving projected values. + /// Filter delegate. + /// Projection delegate. + private sealed class WhereSelectObserver( + IObserver downstream, + Func predicate, + Func selector) : IObserver + { + /// + public void OnNext(TIn value) + { + TOut projected; + try + { + if (!predicate(value)) + { + return; + } + + projected = selector(value); + } + catch (Exception ex) + { + downstream.OnError(ex); + return; + } + + downstream.OnNext(projected); + } + + /// + public void OnError(Exception error) => downstream.OnError(error); + + /// + public void OnCompleted() => downstream.OnCompleted(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/WhereTrueObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/WhereTrueObservable.cs new file mode 100644 index 0000000..c6c19f5 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/WhereTrueObservable.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Filtering operator that forwards only true values from a boolean source. +/// Replaces the source.Where(b => b) pattern with a dedicated forwarding +/// observer, avoiding the per-subscription closure allocation that the predicate +/// lambda would otherwise capture. +/// +/// The boolean source observable. +internal sealed class WhereTrueObservable(IObservable source) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(observer); + return source.Subscribe(new WhereTrueObserver(observer)); + } + + /// + /// Forwarding observer that drops false values and passes true + /// values, terminal error and completion through unchanged. + /// + /// The downstream observer. + private sealed class WhereTrueObserver(IObserver downstream) : IObserver + { + /// + public void OnNext(bool value) + { + if (!value) + { + return; + } + + downstream.OnNext(true); + } + + /// + public void OnError(Exception error) => downstream.OnError(error); + + /// + public void OnCompleted() => downstream.OnCompleted(); + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/Operators/WhileObservable.cs b/src/ReactiveUI.Primitives.Extensions/Operators/WhileObservable.cs new file mode 100644 index 0000000..4f218fe --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Operators/WhileObservable.cs @@ -0,0 +1,155 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Operators; + +/// +/// Loops the supplied on the supplied +/// (or inline when no scheduler is provided), emitting +/// after each iteration, for as long as +/// returns true. Replaces the legacy +/// Observable.While(condition, Observable.Start(action, scheduler)) +/// pattern. +/// +/// The loop predicate. Evaluated before each iteration. +/// The action to invoke per iteration. +/// An optional scheduler; null runs every iteration inline. +internal sealed class WhileObservable( + Func condition, + Action action, + ISequencer? scheduler) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(condition); + InvalidOperationExceptionHelper.ThrowIfNull(action); + ArgumentExceptionHelper.ThrowIfNull(observer); + + var sink = new WhileSink(observer, condition, action, scheduler); + sink.Run(); + return sink; + } + + /// + /// Sink that orchestrates the iteration loop, scheduling the next iteration + /// after each emission and terminating when the predicate becomes false. + /// + private sealed class WhileSink : IDisposable + { + /// The downstream observer. + private readonly IObserver _downstream; + + /// The loop predicate. + private readonly Func _condition; + + /// The action invoked per iteration. + private readonly Action _action; + + /// An optional scheduler used per iteration. + private readonly ISequencer? _scheduler; + + /// The disposable tracking the currently-scheduled iteration. + private readonly MutableDisposable _current = new(); + + /// Whether the sink has been disposed. + private int _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The downstream observer. + /// The loop predicate. + /// The action invoked per iteration. + /// An optional scheduler used per iteration. + public WhileSink(IObserver downstream, Func condition, Action action, ISequencer? scheduler) + { + _downstream = downstream; + _condition = condition; + _action = action; + _scheduler = scheduler; + } + + /// Starts the loop. + public void Run() => Iterate(); + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) + { + return; + } + + _current.Dispose(); + } + + /// + /// Performs a single iteration: checks the predicate, runs the action, + /// emits , and schedules the next iteration. + /// + private void Iterate() + { + if (Volatile.Read(ref _disposed) == 1) + { + return; + } + + try + { + if (!_condition()) + { + _downstream.OnCompleted(); + return; + } + } + catch (Exception error) + { + _downstream.OnError(error); + return; + } + + if (_scheduler is null) + { + RunActionAndContinue(); + return; + } + + _current.Disposable = _scheduler.Schedule(this, static (_, self) => + { + self.RunActionAndContinue(); + return EmptyDisposable.Instance; + }); + } + + /// + /// Runs the per-iteration action, emits , and re-enters + /// to evaluate the predicate for the next iteration. + /// + private void RunActionAndContinue() + { + if (Volatile.Read(ref _disposed) == 1) + { + return; + } + + try + { + _action(); + } + catch (Exception error) + { + _downstream.OnError(error); + return; + } + + _downstream.OnNext(RxVoid.Default); + Iterate(); + } + } +} diff --git a/src/ReactiveUI.Primitives.Extensions/ReactiveExtensions.cs b/src/ReactiveUI.Primitives.Extensions/ReactiveExtensions.cs new file mode 100644 index 0000000..a0fb8ec --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/ReactiveExtensions.cs @@ -0,0 +1,1649 @@ +// 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.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Text.RegularExpressions; + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Internal; +using ReactiveUI.Primitives.Extensions.Operators; + +namespace ReactiveUI.Primitives.Extensions; + +/// +/// Extension methods for Reactive objects. +/// +[SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with \'Async\'", Justification = "Existing API")] +public static class ReactiveExtensions +{ + /// Default backoff factor for : each retry doubles the previous delay. + private const double DefaultBackoffFactor = 2.0; + + /// + /// Returns only values that are not null. + /// Converts the nullability. + /// + /// The type of value emitted by the observable. + /// The observable that can contain nulls. + /// A non nullable version of the observable that only emits valid values. + public static IObservable WhereIsNotNull(this IObservable observable) => + new WhereIsNotNullObservable(observable); + + /// + /// Change the source observable type to . + /// This allows us to be notified when the observable emits a value. + /// + /// The current type of the observable. + /// The observable to convert. + /// The signal. + public static IObservable AsSignal(this IObservable observable) => + new AsSignalObservable(observable); + + /// + /// Synchronized timer all instances of this with the same TimeSpan use the same timer. + /// + /// The time span. + /// An observable sequence producing the shared DateTime ticks. + public static IObservable SyncTimer(TimeSpan timeSpan) => + SyncTimerObservable.Get(timeSpan, Sequencer.Default); + + /// + /// Synchronized timer all instances of this with the same TimeSpan and scheduler use the same timer. + /// + /// The time span. + /// Scheduler used to emit ticks. + /// An observable sequence producing the shared DateTime ticks. + public static IObservable SyncTimer(TimeSpan timeSpan, ISequencer scheduler) => + SyncTimerObservable.Get(timeSpan, scheduler); + + /// + /// Buffers until Start char and End char are found. + /// + /// The source observable of characters. + /// The starting delimiter. + /// The ending delimiter. + /// A sequence of buffered strings including the start and end delimiters. + public static IObservable BufferUntil(this IObservable @this, char startsWith, char endsWith) => + new BufferUntilObservable(@this, startsWith, endsWith); + + /// + /// Emit a batch when the stream goes quiet. + /// + /// The type of the elements in the source sequence. + /// The source. + /// The idle time. + /// A sequence of buffered lists. + public static IObservable> BufferUntilIdle( + this IObservable source, + TimeSpan idleTime) => + source.BufferUntilIdle(idleTime, null); + + /// + /// Emit a batch when the stream goes quiet. + /// + /// The type of the elements in the source sequence. + /// The source. + /// The idle time. + /// The scheduler. + /// A sequence of buffered lists. + public static IObservable> BufferUntilIdle( + this IObservable source, + TimeSpan idleTime, + ISequencer? scheduler) => + new BufferUntilIdleObservable(source, idleTime, scheduler ?? Sequencer.Default); + + /// + /// Catch exception and return Observable.Empty. + /// + /// The type of the source. + /// The source. + /// A sequence that ignores errors and completes. + public static IObservable CatchIgnore(this IObservable source) => + new CatchIgnoreEmptyObservable(source); + + /// + /// Catch exception and return Observable.Empty. + /// + /// The type of the source. + /// The type of the exception. + /// The source. + /// The error action. + /// A sequence that invokes on error and completes. + public static IObservable CatchIgnore( + this IObservable source, + Action errorAction) + where TException : Exception => + new CatchIgnoreObservable(source, errorAction); + + /// + /// Latest values of each sequence are all false. + /// + /// The sources. + /// A sequence that emits true when all latest booleans are false. + public static IObservable CombineLatestValuesAreAllFalse(this IEnumerable> sources) => + new BooleanReduceObservable(sources, target: false); + + /// + /// Latest values of each sequence are all true. + /// + /// The sources. + /// A sequence that emits true when all latest booleans are true. + public static IObservable CombineLatestValuesAreAllTrue(this IEnumerable> sources) => + new BooleanReduceObservable(sources, target: true); + + /// + /// Gets the maximum from all sources. + /// + /// The Value Type. + /// The first observable. + /// Other sources. + /// A sequence emitting the maximum of the latest values. + public static IObservable GetMax(this IObservable @this, params IObservable[] sources) + where T : struct, IComparable + { + var allSources = new IObservable[sources.Length + 1]; + allSources[0] = @this; + Array.Copy(sources, 0, allSources, 1, sources.Length); + return new MinMaxObservable(allSources, emitMaximum: true); + } + + /// + /// Gets the minimum from all sources. + /// + /// The Value Type. + /// The first observable. + /// Other sources. + /// A sequence emitting the minimum of the latest values. + public static IObservable GetMin(this IObservable @this, params IObservable[] sources) + where T : struct, IComparable + { + var allSources = new IObservable[sources.Length + 1]; + allSources[0] = @this; + Array.Copy(sources, 0, allSources, 1, sources.Length); + return new MinMaxObservable(allSources, emitMaximum: false); + } + + /// + /// Detects when a stream becomes inactive for some period of time. + /// + /// update type. + /// source stream. + /// If source stream does not OnNext any update during this period, it is declared stale. + /// The scheduler. + /// Observable stale markers or updates. + public static IObservable> DetectStale( + this IObservable source, + TimeSpan stalenessPeriod, + ISequencer scheduler) => + new DetectStaleObservable(source, stalenessPeriod, scheduler); + + /// + /// Applies a conflation algorithm to an observable stream. Anytime the stream OnNext twice + /// below minimumUpdatePeriod, the second update gets delayed to respect the + /// minimumUpdatePeriod. If more than 2 updates happen, only the last update is pushed. + /// + /// The type. + /// The stream. + /// Minimum delay between two updates. + /// Scheduler to publish updates. + /// The conflated stream. + public static IObservable Conflate( + this IObservable source, + TimeSpan minimumUpdatePeriod, + ISequencer scheduler) => + new ConflateObservable(source, minimumUpdatePeriod, scheduler); + + /// + /// Injects heartbeats in a stream when the source stream becomes quiet. + /// + /// Update type. + /// Source stream. + /// Period between heartbeats. + /// Scheduler. + /// Observable heartbeat values. + public static IObservable> Heartbeat( + this IObservable source, + TimeSpan heartbeatPeriod, + ISequencer scheduler) => + new HeartbeatObservable(source, heartbeatPeriod, scheduler); + + /// + /// Emit the latest value or a default if none exists. + /// + /// The type of the source. + /// The source. + /// The default value. + /// A sequence that emits the latest value or the default. + public static IObservable LatestOrDefault( + this IObservable source, + T defaultValue) => new LatestOrDefaultObservable(source, defaultValue); + + /// + /// Logs the errors. Inline error logging without terminating the stream. + /// + /// The type of the source. + /// The source. + /// The logger. + /// A sequence that logs errors. + public static IObservable LogErrors( + this IObservable source, + Action logger) + { + ArgumentExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(logger); + return new LogErrorsObservable(source, logger); + } + + /// + /// Executes with limited concurrency. + /// + /// The result type. + /// Tasks to execute. + /// Maximum concurrency. + /// A sequence of task results. + public static IObservable WithLimitedConcurrency(this IEnumerable> taskFunctions, int maxConcurrency) + { + ArgumentExceptionHelper.ThrowIfNull(taskFunctions); + + return new ConcurrencyLimiter(taskFunctions, maxConcurrency).Observable; + } + + /// + /// Pushes multiple values to an observer. + /// + /// Type of value. + /// Observer to push to. + /// Values to push. + public static void OnNext(this IObserver observer, params T[] events) + { + ArgumentExceptionHelper.ThrowIfNull(observer); + ArgumentExceptionHelper.ThrowIfNull(events); + + observer.FastForEach(events); + } + + /// + /// If the scheduler is not null observes on that scheduler. + /// + /// Element type. + /// Source sequence. + /// Scheduler to notify observers on (optional). + /// The source sequence whose callbacks happen on the specified scheduler. + public static IObservable + ObserveOnSafe(this IObservable source, ISequencer? scheduler) => + scheduler == null ? source : new ObserveOnObservable(source, scheduler); + + /// + /// Conditionally switch schedulers. + /// + /// The type. + /// The source. + /// if set to true [condition]. + /// The scheduler. + /// An IObservable of T. + public static IObservable ObserveOnIf( + this IObservable source, + bool condition, + ISequencer scheduler) => condition ? new ObserveOnObservable(source, scheduler) : source; + + /// + /// Conditionally switch schedulers. + /// + /// The type. + /// The source. + /// if set to true [condition]. + /// The true scheduler. + /// The false scheduler. + /// An IObservable of T. + public static IObservable ObserveOnIf( + this IObservable source, + bool condition, + ISequencer trueScheduler, + ISequencer falseScheduler) => condition + ? new ObserveOnObservable(source, trueScheduler) + : new ObserveOnObservable(source, falseScheduler); + + /// + /// Conditionally switch schedulers based on a reactive condition. + /// + /// The type. + /// The source. + /// The reactive condition. + /// The scheduler to use when condition is true. + /// The scheduler to use when condition is false. + /// An IObservable of T. + public static IObservable ObserveOnIf( + this IObservable source, + IObservable condition, + ISequencer trueScheduler, + ISequencer falseScheduler) => + new ObserveOnIfObservable(source, condition, trueScheduler, falseScheduler); + + /// + /// Conditionally switch schedulers based on a reactive condition. + /// + /// The type. + /// The source. + /// The reactive condition. + /// The scheduler to use when condition is true. + /// An IObservable of T. + public static IObservable ObserveOnIf( + this IObservable source, + IObservable condition, + ISequencer scheduler) => + new ObserveOnIfObservable(source, condition, scheduler, Sequencer.Immediate); + + /// + /// Skip null values until the first non-null appears. + /// + /// The type. + /// The source. + /// An IObservable of T. + public static IObservable SkipWhileNull(this IObservable source) + where T : class + { + ArgumentExceptionHelper.ThrowIfNull(source); + return new SkipWhileNullObservable(source); + } + + /// + /// Invokes the action asynchronously surfacing the result through a RxVoid observable. + /// + /// Action to run. + /// Scheduler (optional). + /// A sequence producing RxVoid upon completion. + public static IObservable Start(Action action, ISequencer? scheduler) => + new StartActionObservable(action, scheduler); + + /// + /// Invokes the specified function asynchronously surfacing the result. + /// + /// Result type. + /// Function to run. + /// Scheduler. + /// A sequence producing the function result. + public static IObservable Start(Func function, ISequencer? scheduler) => + new StartFuncObservable(function, scheduler); + + /// + /// Flattens a sequence of enumerables into individual values. + /// + /// Element type. + /// Source of enumerables. + /// A flattened observable. + public static IObservable ForEach(this IObservable> source) => + source.ForEach(null); + + /// + /// Flattens a sequence of enumerables into individual values. + /// + /// Element type. + /// Source of enumerables. + /// Scheduler (optional). + /// A flattened observable. + public static IObservable ForEach(this IObservable> source, ISequencer? scheduler) => + new ForEachObservable(source, scheduler); + + /// + /// Schedules an action immediately if scheduler null, else on scheduler. + /// + /// Scheduler. + /// Action. + /// Disposable for the scheduled action. + public static IDisposable ScheduleSafe(this ISequencer? scheduler, Action action) + { + ArgumentExceptionHelper.ThrowIfNull(action); + + if (scheduler != null) + { + return scheduler.Schedule(action); + } + + action(); + return EmptyDisposable.Instance; + } + + /// + /// Schedules an action after a due time. + /// + /// Scheduler. + /// Delay. + /// Action. + /// Disposable for the scheduled action. + public static IDisposable ScheduleSafe(this ISequencer? scheduler, TimeSpan dueTime, Action action) + { + ArgumentExceptionHelper.ThrowIfNull(action); + + if (scheduler == null) + { + Thread.Sleep(dueTime); + action(); + return EmptyDisposable.Instance; + } + + return scheduler.Schedule(dueTime, action); + } + + /// + /// Emits each element of an IEnumerable. + /// + /// Element type. + /// Source enumerable. + /// Observable of elements. + public static IObservable FromArray(this IEnumerable source) => + source.FromArray(null); + + /// + /// Emits each element of an IEnumerable. + /// + /// Element type. + /// Source enumerable. + /// Scheduler (optional). + /// Observable of elements. + public static IObservable FromArray(this IEnumerable source, ISequencer? scheduler) => + new FromArrayObservable(source, scheduler); + + /// + /// Using helper with Action. + /// + /// Disposable type. + /// Object to use. + /// Action to run. + /// Completion signal. + public static IObservable Using(this T obj, Action? action) + where T : IDisposable => + obj.Using(action, null); + + /// + /// Using helper with Action. + /// + /// Disposable type. + /// Object to use. + /// Action to run. + /// Scheduler. + /// Completion signal. + public static IObservable Using(this T obj, Action? action, ISequencer? scheduler) + where T : IDisposable => + new UsingActionObservable(obj, action, scheduler); + + /// + /// Using helper with Func. + /// + /// Disposable type. + /// Result type. + /// Object to use. + /// Function to invoke. + /// Observable of result. + public static IObservable Using( + this T obj, + Func function) + where T : IDisposable => obj.Using(function, null); + + /// + /// Using helper with Func. + /// + /// Disposable type. + /// Result type. + /// Object to use. + /// Function to invoke. + /// Scheduler. + /// Observable of result. + public static IObservable Using( + this T obj, + Func function, + ISequencer? scheduler) + where T : IDisposable => new UsingFuncObservable(obj, function, scheduler); + + /// + /// While construct. + /// + /// Condition to evaluate. + /// Action to execute. + /// Observable representing the loop. + public static IObservable While(Func condition, Action action) => + While(condition, action, null); + + /// + /// While construct. + /// + /// Condition to evaluate. + /// Action to execute. + /// Scheduler. + /// Observable representing the loop. + public static IObservable While(Func condition, Action action, ISequencer? scheduler) => + new WhileObservable(condition, action, scheduler); + + /// + /// Sample the latest value whenever a trigger fires. + /// + /// The type. + /// The source. + /// The trigger. + /// An IObservable of T. + public static IObservable SampleLatest( + this IObservable source, + IObservable trigger) => new SampleLatestObservable(source, trigger); + + /// + /// Scan that always emits the initial value first. + /// + /// The type of the source. + /// The type of the accumulate. + /// The source. + /// The initial. + /// The accumulator. + /// An IObservable of TAccumulate. + public static IObservable ScanWithInitial( + this IObservable source, + TAccumulate initial, + Func accumulator) => + new ScanWithInitialObservable(source, initial, accumulator); + + /// + /// Schedules a single value after a delay. + /// + /// Value type. + /// Value. + /// Delay. + /// Scheduler. + /// Observable that emits the value. + public static IObservable Schedule(this T value, TimeSpan dueTime, ISequencer scheduler) => + new ScheduledValueObservable(value, scheduler, dueTime, null, null, null); + + /// + /// Schedules the specified due time. + /// + /// The type. + /// The value. + /// The due time. + /// The scheduler. + /// An IObservable of T. + public static IObservable Schedule(this IObservable source, TimeSpan dueTime, ISequencer scheduler) => + new ScheduledSourceObservable(source, ScheduleConfig.Delayed(scheduler, dueTime)); + + /// + /// Schedules the specified due time. + /// + /// The type. + /// The value. + /// The due time. + /// The scheduler. + /// An IObservable of T. + public static IObservable Schedule(this T value, DateTimeOffset dueTime, ISequencer scheduler) => + new ScheduledValueObservable(value, scheduler, null, dueTime, null, null); + + /// + /// Schedules the specified due time. + /// + /// The type. + /// The value. + /// The due time. + /// The scheduler. + /// An IObservable of T. + public static IObservable + Schedule(this IObservable source, DateTimeOffset dueTime, ISequencer scheduler) => + new ScheduledSourceObservable(source, ScheduleConfig.Absolute(scheduler, dueTime)); + + /// + /// Schedules the specified due time. + /// + /// The type. + /// The value. + /// The due time. + /// The scheduler. + /// The action. + /// An IObservable of T. + public static IObservable Schedule(this T value, TimeSpan dueTime, ISequencer scheduler, Action action) => + new ScheduledValueObservable(value, scheduler, dueTime, null, null, action); + + /// + /// Schedules the specified due time. + /// + /// The type. + /// The value. + /// The due time. + /// The scheduler. + /// The action. + /// An IObservable of T. + public static IObservable Schedule( + this IObservable source, + TimeSpan dueTime, + ISequencer scheduler, + Action action) => + new ScheduledSourceObservable(source, ScheduleConfig.Delayed(scheduler, dueTime).WithAction(action)); + + /// + /// Schedules the specified due time. + /// + /// The type. + /// The value. + /// The due time. + /// The scheduler. + /// The action. + /// An IObservable of T. + public static IObservable Schedule( + this T value, + DateTimeOffset dueTime, + ISequencer scheduler, + Action action) => + new ScheduledValueObservable(value, scheduler, null, dueTime, null, action); + + /// + /// Schedules the specified due time. + /// + /// The type. + /// The value. + /// The due time. + /// The scheduler. + /// The action. + /// An IObservable of T. + public static IObservable Schedule( + this IObservable source, + DateTimeOffset dueTime, + ISequencer scheduler, + Action action) => + new ScheduledSourceObservable(source, ScheduleConfig.Absolute(scheduler, dueTime).WithAction(action)); + + /// + /// Schedules the specified due time. + /// + /// The type. + /// The value. + /// The scheduler. + /// The function. + /// An IObservable of T. + public static IObservable Schedule(this T value, ISequencer scheduler, Func function) => + new ScheduledValueObservable(value, scheduler, null, null, function, null); + + /// + /// Schedules the specified due time. + /// + /// The type. + /// The value. + /// The scheduler. + /// The function. + /// An IObservable of T. + public static IObservable Schedule(this IObservable source, ISequencer scheduler, Func function) => + new ScheduledSourceObservable(source, ScheduleConfig.Immediate(scheduler).WithTransform(function)); + + /// + /// Schedules the specified due time. + /// + /// The type. + /// The value. + /// The due time. + /// The scheduler. + /// The function. + /// An IObservable of T. + public static IObservable + Schedule(this T value, TimeSpan dueTime, ISequencer scheduler, Func function) => + new ScheduledValueObservable(value, scheduler, dueTime, null, function, null); + + /// + /// Schedules the specified due time. + /// + /// The type. + /// The value. + /// The due time. + /// The scheduler. + /// The function. + /// An IObservable of T. + public static IObservable Schedule( + this IObservable source, + TimeSpan dueTime, + ISequencer scheduler, + Func function) => + new ScheduledSourceObservable(source, ScheduleConfig.Delayed(scheduler, dueTime).WithTransform(function)); + + /// + /// Filters strings by regex. + /// + /// Source sequence. + /// Regex pattern. + /// Filtered sequence. + public static IObservable Filter(this IObservable source, string regexPattern) => + source.Filter(new Regex(regexPattern, RegexOptions.None, TimeSpan.FromSeconds(1))); + + /// + /// Filters strings by regex. + /// + /// Source sequence. + /// Regex. + /// Filtered sequence. + public static IObservable Filter(this IObservable source, Regex regex) => + new FilterRegexObservable(source, regex); + + /// + /// Randomly shuffles arrays emitted by the source. + /// + /// Array element type. + /// Source array sequence. + /// Sequence of shuffled arrays (in-place). + public static IObservable Shuffle(this IObservable source) => new ShuffleObservable(source); + + /// + /// Repeats the source until it terminates successfully (alias of Retry). + /// + /// Element type. + /// Source sequence. + /// Retried sequence. + public static IObservable OnErrorRetry(this IObservable source) + { + ArgumentExceptionHelper.ThrowIfNull(source); + return new RetryForeverObservable(source); + } + + /// + /// When caught exception, do onError action and repeat observable sequence. + /// + /// The type of the source. + /// The type of the exception. + /// The source. + /// The on error. + /// A sequence that retries on error with optional delay. + public static IObservable OnErrorRetry( + this IObservable source, + Action onError) + where TException : Exception => source.OnErrorRetry(onError, TimeSpan.Zero); + + /// + /// When caught exception, do onError action and repeat observable sequence after delay time. + /// + /// The type of the source. + /// The type of the exception. + /// The source. + /// The on error. + /// The delay. + /// A sequence that retries on error with optional delay. + public static IObservable OnErrorRetry( + this IObservable source, + Action onError, + TimeSpan delay) + where TException : Exception => source.OnErrorRetry(onError, int.MaxValue, delay); + + /// + /// When caught exception, do onError action and repeat observable sequence during within retryCount. + /// + /// The type of the source. + /// The type of the exception. + /// The source. + /// The on error. + /// The retry count. + /// A sequence that retries on error with optional delay. + public static IObservable OnErrorRetry( + this IObservable source, + Action onError, + int retryCount) + where TException : Exception => source.OnErrorRetry(onError, retryCount, TimeSpan.Zero); + + /// + /// When caught exception, do onError action and repeat observable sequence after delay time + /// during within retryCount. + /// + /// The type of the source. + /// The type of the exception. + /// The source. + /// The on error. + /// The retry count. + /// The delay. + /// A sequence that retries on error with optional delay. + public static IObservable OnErrorRetry( + this IObservable source, + Action onError, + int retryCount, + TimeSpan delay) + where TException : Exception => source.OnErrorRetry(onError, retryCount, delay, Sequencer.Default); + + /// + /// When caught exception, do onError action and repeat observable sequence after delay + /// time(work on delayScheduler) during within retryCount. + /// + /// The type of the source. + /// The type of the exception. + /// The source. + /// The on error. + /// The retry count. + /// The delay. + /// The delay scheduler. + /// A sequence that retries on error with optional delay. + public static IObservable OnErrorRetry( + this IObservable source, + Action onError, + int retryCount, + TimeSpan delay, + ISequencer delayScheduler) + where TException : Exception + { + ArgumentExceptionHelper.ThrowIfNull(source); + + return new RetryWithBackoffObservable( + source, + new RetryBackoffPolicy( + MaxRetries: retryCount, + InitialDelay: delay, + BackoffFactor: 1.0, + MaxDelay: null, + Scheduler: delayScheduler, + OnError: ex => + { + if (ex is not TException tex) + { + return; + } + + onError(tex); + })); + } + + /// + /// Takes elements until predicate returns true for an element (inclusive) then completes. + /// + /// Element type. + /// Source sequence. + /// Predicate for completion. + /// Sequence that completes when predicate satisfied. + public static IObservable TakeUntil( + this IObservable source, + Func predicate) + { + ArgumentExceptionHelper.ThrowIfNull(predicate); + return new TakeUntilInclusiveObservable(source, predicate); + } + + /// + /// Wraps values with a synchronization disposable that completes when disposed. + /// + /// Element type. + /// Source sequence. + /// Sequence of (value, sync handle). + public static IObservable<(T Value, IDisposable Sync)> SynchronizeSynchronous(this IObservable source) => + new SynchronizeAsyncObservable(source); + + /// + /// Subscribes to the specified source synchronously. + /// + /// The type of the elements in the source sequence. + /// The source. + /// The on next. + /// The on error. + /// The on completed. + /// object used to unsubscribe from the observable sequence. + public static IDisposable SubscribeSynchronous( + this IObservable source, + Func onNext, + Action onError, + Action onCompleted) => + new SubscribeAsyncObservable(source, onNext, onError, onCompleted); + + /// + /// Subscribes an element handler and an exception handler to an observable sequence synchronously. + /// + /// The type of the elements in the source sequence. + /// Observable sequence to subscribe to. + /// Action to invoke for each element in the observable sequence. + /// Action to invoke upon exceptional termination of the observable sequence. + /// object used to unsubscribe from the observable sequence. + public static IDisposable SubscribeSynchronous( + this IObservable source, + Func onNext, + Action onError) => + new SubscribeAsyncObservable(source, onNext, onError); + + /// + /// Subscribes an element handler and a completion handler to an observable sequence synchronously. + /// + /// The type of the elements in the source sequence. + /// Observable sequence to subscribe to. + /// Action to invoke for each element in the observable sequence. + /// Action to invoke upon graceful termination of the observable sequence. + /// object used to unsubscribe from the observable sequence. + /// or or is null. + public static IDisposable SubscribeSynchronous( + this IObservable source, + Func onNext, + Action onCompleted) => + new SubscribeAsyncObservable(source, onNext, onCompleted: onCompleted); + + /// + /// Subscribes an element handler to an observable sequence synchronously. + /// + /// The type of the elements in the source sequence. + /// Observable sequence to subscribe to. + /// Action to invoke for each element in the observable sequence. + /// object used to unsubscribe from the observable sequence. + public static IDisposable SubscribeSynchronous(this IObservable source, Func onNext) => + new SubscribeAsyncObservable(source, onNext); + + /// + /// Provide a fallback observable if the source completes without emitting. + /// + /// The type. + /// The source. + /// The fallback. + /// An IObservable of T. + public static IObservable SwitchIfEmpty( + this IObservable source, + IObservable fallback) => new SwitchIfEmptyObservable(source, fallback); + + /// + /// Synchronizes the asynchronous operations in downstream operations. + /// Use SubscribeSynchronus instead for a simpler version. + /// Call Sync.Dispose() to release the lock in the downstream methods. + /// + /// The type of the elements in the source sequence. + /// The source. + /// An Observable of T and a release mechanism. + public static IObservable<(T Value, IDisposable Sync)> SynchronizeAsync(this IObservable source) => + new SynchronizeAsyncObservable(source); + + /// + /// Subscribes allowing asynchronous operations to be executed without blocking the source. + /// + /// The type of the elements in the source sequence. + /// Observable sequence to subscribe to. + /// Action to invoke for each element in the observable sequence. + /// object used to unsubscribe from the observable sequence. + [SuppressMessage( + "Roslynator", + "RCS1047:Non-asynchronous method name should not end with \'Async\'", + Justification = "This is an existing method")] + public static IDisposable SubscribeAsync(this IObservable source, Func onNext) => + new SubscribeAsyncObservable(source, onNext); + + /// + /// Subscribes allowing asynchronous operations to be executed without blocking the source. + /// + /// The type of the elements in the source sequence. + /// Observable sequence to subscribe to. + /// Action to invoke for each element in the observable sequence. + /// The on completed. + /// + /// object used to unsubscribe from the observable sequence. + /// + [SuppressMessage( + "Roslynator", + "RCS1047:Non-asynchronous method name should not end with \'Async\'", + Justification = "This is an existing method")] + public static IDisposable SubscribeAsync(this IObservable source, Func onNext, Action onCompleted) => + new SubscribeAsyncObservable(source, onNext, onCompleted: onCompleted); + + /// + /// Subscribes allowing asynchronous operations to be executed without blocking the source. + /// + /// The type of the elements in the source sequence. + /// Observable sequence to subscribe to. + /// Action to invoke for each element in the observable sequence. + /// The on error. + /// + /// object used to unsubscribe from the observable sequence. + /// + [SuppressMessage( + "Roslynator", + "RCS1047:Non-asynchronous method name should not end with \'Async\'", + Justification = "This is an existing method")] + public static IDisposable SubscribeAsync( + this IObservable source, + Func onNext, + Action onError) => + new SubscribeAsyncObservable(source, onNext, onError); + + /// + /// Subscribes allowing asynchronous operations to be executed without blocking the source. + /// + /// The type of the elements in the source sequence. + /// Observable sequence to subscribe to. + /// Action to invoke for each element in the observable sequence. + /// The on error. + /// The on completed. + /// + /// object used to unsubscribe from the observable sequence. + /// + [SuppressMessage( + "Roslynator", + "RCS1047:Non-asynchronous method name should not end with \'Async\'", + Justification = "This is an existing method")] + public static IDisposable SubscribeAsync( + this IObservable source, + Func onNext, + Action onError, + Action onCompleted) => + new SubscribeAsyncObservable(source, onNext, onError, onCompleted); + + /// + /// Emits the boolean negation of the source sequence. + /// + /// Boolean source. + /// Negated boolean sequence. + public static IObservable Not(this IObservable source) => new NotObservable(source); + + /// + /// Filters to true values only. + /// + /// Boolean source. + /// Sequence of true values. + public static IObservable WhereTrue(this IObservable source) => new WhereTrueObservable(source); + + /// + /// Filters to false values only. + /// + /// Boolean source. + /// Sequence of false values. + public static IObservable WhereFalse(this IObservable source) => new WhereFalseObservable(source); + + /// + /// Catches any error and returns a fallback value then completes. + /// + /// Element type. + /// Source sequence. + /// Fallback value. + /// Sequence producing either original values or fallback on error then completing. + public static IObservable CatchAndReturn(this IObservable source, T fallback) => + new CatchReturnObservable(source, fallback); + + /// + /// Catches a specific exception type mapping it to a fallback value. + /// + /// Element type. + /// Exception type. + /// Source sequence. + /// Factory producing fallback from the exception. + /// Recovered sequence. + public static IObservable CatchAndReturn( + this IObservable source, + Func fallbackFactory) + where TException : Exception + { + ArgumentExceptionHelper.ThrowIfNull(fallbackFactory); + return new CatchAndReturnWithFactoryObservable(source, fallbackFactory); + } + + /// + /// Retries with exponential backoff. + /// + /// Element type. + /// Source sequence. + /// Maximum number of retries. + /// Initial backoff delay. + /// Retried sequence with backoff. + public static IObservable RetryWithBackoff( + this IObservable source, + int maxRetries, + TimeSpan initialDelay) => + source.RetryWithBackoff(maxRetries, initialDelay, DefaultBackoffFactor, null, null); + + /// + /// Retries with exponential backoff. + /// + /// Element type. + /// Source sequence. + /// Maximum number of retries. + /// Initial backoff delay. + /// Multiplier for each retry (default 2). + /// Optional maximum delay. + /// Scheduler (optional). + /// Retried sequence with backoff. + public static IObservable RetryWithBackoff( + this IObservable source, + int maxRetries, + TimeSpan initialDelay, + double backoffFactor, + TimeSpan? maxDelay, + ISequencer? scheduler) => + new RetryWithBackoffObservable( + source, + new RetryBackoffPolicy( + MaxRetries: maxRetries, + InitialDelay: initialDelay, + BackoffFactor: backoffFactor, + MaxDelay: maxDelay, + Scheduler: scheduler ?? Sequencer.Default, + OnError: null)); + + /// + /// Retry with exponential. + /// + /// The type. + /// The source. + /// The retry count. + /// The delay selector. + /// An IObservable of T. + public static IObservable RetryWithDelay( + this IObservable source, + int retryCount, + Func delaySelector) => + new RetryWithDelayObservable(source, retryCount, delaySelector); + + /// + /// Retries the forever with delay. + /// + /// The type. + /// The source. + /// The delay. + /// An IObservable of T. + public static IObservable RetryForeverWithDelay(this IObservable source, TimeSpan delay) => + new RetryWithDelayObservable(source, int.MaxValue, _ => delay); + + /// + /// Retry with fixed backoff. + /// + /// The type. + /// The source. + /// The retry count. + /// The delay. + /// An IObservable of T. + public static IObservable RetryWithFixedDelay( + this IObservable source, + int retryCount, + TimeSpan delay) + => new RetryWithBackoffObservable( + source, + new RetryBackoffPolicy( + MaxRetries: retryCount, + InitialDelay: delay, + BackoffFactor: 1.0, + MaxDelay: null, + Scheduler: Sequencer.Default, + OnError: null)); + + /// + /// Always replay the last value, even if the source hasn’t produced one yet. + /// + /// The type. + /// The source. + /// The initial value. + /// An IObservable of T. + public static IObservable ReplayLastOnSubscribe( + this IObservable source, + T initialValue) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + return new ReplayLastOnSubscribeObservable(source, initialValue); + } + + /// + /// Emits only the first value in each time window. + /// + /// Element type. + /// Source sequence. + /// Time window. + /// Throttle-first sequence. + public static IObservable ThrottleFirst( + this IObservable source, + TimeSpan window) => + source.ThrottleFirst(window, null); + + /// + /// Emits only the first value in each time window. + /// + /// Element type. + /// Source sequence. + /// Time window. + /// Scheduler (optional). + /// Throttle-first sequence. + public static IObservable ThrottleFirst( + this IObservable source, + TimeSpan window, + ISequencer? scheduler) => + new ThrottleFirstObservable(source, window, scheduler ?? Sequencer.Default); + + /// + /// Throttle until a predicate becomes true. + /// + /// The type. + /// The source. + /// The throttle. + /// The predicate. + /// An IObservable of T. + public static IObservable ThrottleUntilTrue( + this IObservable source, + TimeSpan throttle, + Func predicate) => + new ThrottleUntilTrueObservable(source, throttle, predicate); + + /// + /// Throttles the on scheduler. + /// + /// The type. + /// The source. + /// The time span. + /// A scheduler for the operation. + /// A observable for the throttle operation. + public static IObservable ThrottleOnScheduler( + this IObservable source, + TimeSpan timeSpan, + ISequencer scheduler) + { + ArgumentExceptionHelper.ThrowIfNull(scheduler); + return new ThrottleObservable(source, timeSpan, scheduler); + } + + /// + /// Builds a current-value subject pair: a read-only observable and the push-side observer. + /// + /// The type. + /// The initial value. + /// A tuple of IObservable and IObserver. + public static (IObservable Observable, IObserver Observer) ToReadOnlyBehavior(T initialValue) + { + var subject = new CurrentValueSubject(initialValue); + return (subject.AsObservable(), subject); + } + + /// + /// Convert an observable to a Task that starts immediately. + /// + /// The type. + /// The source. + /// A Task of T. + public static Task ToHotTask(this IObservable source) => FirstAsTaskHelper.FirstAsTask(source); + + /// + /// Convert an observable to a that starts immediately. Backed by a + /// pooled implementation, so + /// steady-state callers pay no allocations after the per-type pool warms up. Prefer this over + /// when the call site can consume a + /// (single await, no caching, no WhenAll). + /// + /// The type. + /// The source. + /// A that completes with the first value, faults on source error, or faults on empty completion. + public static ValueTask ToHotValueTask(this IObservable source) => + FirstAsValueTaskHelper.FirstAsValueTask(source); + + /// + /// Convert a property getter into an observable that emits on change. + /// + /// The type of the source. + /// The type of the property. + /// The source. + /// The property expression. + /// An IObservable of TProperty. + /// Expression must be a property. + public static IObservable ToPropertyObservable( + this T source, + Expression> propertyExpression) + where T : INotifyPropertyChanged + { + ArgumentExceptionHelper.ThrowIfNull(propertyExpression); + + var member = propertyExpression.Body as MemberExpression + ?? throw new ArgumentException("Expression must be a property"); + + return new PropertyChangedObservable( + source, + member.Member.Name, + propertyExpression.Compile()); + } + + /// + /// Throttle but only emit when the value actually changes. + /// + /// Element type. + /// The source. + /// The throttle. + /// A throttled distinct sequence. + public static IObservable ThrottleDistinct( + this IObservable source, + TimeSpan throttle) => + new ThrottleDistinctObservable(source, throttle, Sequencer.Default); + + /// + /// Throttle but only emit when the value actually changes. + /// + /// Element type. + /// The source. + /// The throttle. + /// The scheduler. + /// A throttled distinct sequence. + public static IObservable ThrottleDistinct( + this IObservable source, + TimeSpan throttle, + ISequencer scheduler) => + new ThrottleDistinctObservable(source, throttle, scheduler); + + /// + /// Debounces with an immediate first emission then standard debounce behavior. + /// + /// Element type. + /// Source sequence. + /// Debounce time. + /// Debounced sequence. + public static IObservable DebounceImmediate( + this IObservable source, + TimeSpan dueTime) => + source.DebounceImmediate(dueTime, null); + + /// + /// Debounces with an immediate first emission then standard debounce behavior. + /// + /// Element type. + /// Source sequence. + /// Debounce time. + /// Scheduler (optional). + /// Debounced sequence. + public static IObservable DebounceImmediate( + this IObservable source, + TimeSpan dueTime, + ISequencer? scheduler) => + new DebounceImmediateObservable(source, dueTime, scheduler ?? Sequencer.Default); + + /// + /// Debounce until a condition becomes true. + /// + /// The type. + /// The source. + /// The debounce. + /// The condition. + /// An IObservable of T. + public static IObservable DebounceUntil( + this IObservable source, + TimeSpan debounce, + Func condition) => + source.DebounceUntil(debounce, condition, null); + + /// + /// Debounce until a condition becomes true. + /// + /// The type. + /// The source. + /// The debounce. + /// The condition. + /// A scheduler for the operation. + /// An IObservable of T. + public static IObservable DebounceUntil( + this IObservable source, + TimeSpan debounce, + Func condition, + ISequencer? scheduler) => + new DebounceUntilObservable(source, debounce, condition, scheduler ?? Sequencer.Default); + + /// + /// Maps values to async operations without losing ordering or cancellation semantics. + /// + /// The type of the source. + /// The type of the result. + /// The source. + /// The asynchronous selector. + /// An IObservable of TResult. + public static IObservable SelectAsync( + this IObservable source, + Func> asyncSelector) => + new SelectAsyncSequentialObservable(source, x => asyncSelector(x, CancellationToken.None)); + + /// + /// Maps values to async operations without losing ordering or cancellation semantics. + /// + /// The type of the source. + /// The type of the result. + /// The source. + /// The asynchronous selector. + /// An IObservable of TResult. + public static IObservable SelectAsync( + this IObservable source, + Func> asyncSelector) => + new SelectAsyncSequentialObservable(source, asyncSelector); + + /// + /// Projects each element to a task executed sequentially. + /// + /// Source element type. + /// Result type. + /// Source sequence. + /// Task selector. + /// Sequence of results preserving order. + public static IObservable SelectAsyncSequential( + this IObservable source, + Func> selector) => + new SelectAsyncSequentialObservable(source, selector); + + /// + /// Projects each element to a task but only latest result is emitted. + /// + /// Source type. + /// Result type. + /// Source sequence. + /// Task selector. + /// Sequence of latest task results. + public static IObservable SelectLatestAsync( + this IObservable source, + Func> selector) => + new SelectLatestAsyncObservable(source, selector); + + /// + /// Projects each element to a task with limited concurrency. + /// + /// Source type. + /// Result type. + /// Source sequence. + /// Task selector. + /// Max concurrency. + /// Merged sequence of task results. + public static IObservable SelectAsyncConcurrent( + this IObservable source, + Func> selector, + int maxConcurrency) => + new SelectAsyncConcurrentObservable(source, selector, maxConcurrency); + + /// + /// Emit (previous, current) pairs. + /// + /// The type. + /// The source. + /// An IObservable of (T Previous, T Current). + public static IObservable<(T Previous, T Current)> Pairwise( + this IObservable source) => new PairwiseObservable(source); + + /// + /// Partitions a sequence into two based on predicate. + /// + /// Element type. + /// Source sequence. + /// Predicate. + /// Tuple of (trueSequence, falseSequence). + public static (IObservable True, IObservable False) Partition( + this IObservable source, + Func predicate) + { + var partition = new PartitionObservable(source, predicate); + return (partition.True, partition.False); + } + + /// + /// Buffers items until inactivity period elapses then emits and resets buffer. + /// + /// Element type. + /// Source sequence. + /// Inactivity period. + /// Sequence of buffered lists. + public static IObservable> BufferUntilInactive( + this IObservable source, + TimeSpan inactivityPeriod) => + source.BufferUntilInactive(inactivityPeriod, null); + + /// + /// Buffers items until inactivity period elapses then emits and resets buffer. + /// + /// Element type. + /// Source sequence. + /// Inactivity period. + /// Scheduler. + /// Sequence of buffered lists. + public static IObservable> BufferUntilInactive( + this IObservable source, + TimeSpan inactivityPeriod, + ISequencer? scheduler) => + new BufferUntilIdleObservable(source, inactivityPeriod, scheduler ?? Sequencer.Default); + + /// + /// Emits the first element matching predicate then completes. + /// + /// Element type. + /// Source sequence. + /// Predicate. + /// Sequence with first matching element. + public static IObservable WaitUntil(this IObservable source, Func predicate) => + new WaitUntilObservable(source, predicate); + + /// + /// Drop values when the previous async operation is still running. + /// + /// The type. + /// The source. + /// The asynchronous action. + /// An IObservable of T. + public static IObservable DropIfBusy( + this IObservable source, + Func asyncAction) => + new DropIfBusyObservable(source, asyncAction); + + /// + /// Executes an action at subscription time. + /// + /// Element type. + /// Source sequence. + /// Action to run on subscribe. + /// Original sequence with subscribe side-effect. + public static IObservable DoOnSubscribe(this IObservable source, Action action) => + new DoOnSubscribeObservable(source, action); + + /// + /// Executes an action when subscription is disposed. + /// + /// Element type. + /// Source sequence. + /// Action to run on dispose. + /// Original sequence with dispose side-effect. + public static IObservable DoOnDispose(this IObservable source, Action disposeAction) => + new DoOnDisposeObservable(source, disposeAction); + + /// + /// Fused Where(predicate).Select(selector). Allocates a single observer + /// per subscription instead of two, eliminating the intermediate operator that + /// the equivalent Rx chain would build. + /// + /// The source element type. + /// The projected element type. + /// The source observable. + /// Filter applied to each source element. + /// Projection applied to elements that pass . + /// A fused filter-and-project observable. + public static IObservable WhereSelect( + this IObservable source, + Func predicate, + Func selector) => + new WhereSelectObservable(source, predicate, selector); + + /// + /// Swallows any source error by emitting the fallback value followed by completion. + /// + /// The element type. + /// The source observable whose values are forwarded verbatim. + /// The value emitted if the source errors. + /// An observable that never produces an error terminal. + public static IObservable CatchReturn(this IObservable source, T fallback) => + new CatchReturnObservable(source, fallback); + + /// + /// Convenience overload: source.CatchReturnUnit() is shorthand for + /// source.CatchReturn(RxVoid.Default). + /// + /// The source observable. + /// An observable that never produces an error terminal — errors are replaced with a single . + public static IObservable CatchReturnUnit(this IObservable source) => + new CatchReturnObservable(source, RxVoid.Default); + + /// + /// Projects every source element to a stored constant, avoiding the closure + /// allocation of .Select(_ => value). Common in fire-then-return-value + /// chains. + /// + /// The source element type (ignored). + /// The result element type. + /// The source observable whose values are ignored. + /// The constant value emitted for each source element. + /// An observable that emits for each source element. + public static IObservable SelectConstant( + this IObservable source, + TResult constant) => + new SelectConstantObservable(source, constant); + + /// + /// Applies and emits only non-null results. + /// Replaces .Select(f).Where(x => x is not null).Select(x => x!) + /// with a single operator allocation. + /// + /// The source element type. + /// The projected element type. + /// The source observable. + /// Projection that may return . + /// An observable that emits only non-null projected values. + public static IObservable TrySelect( + this IObservable source, + Func selector) => + new TrySelectObservable(source, selector); + + /// + /// Chains two one-shot SelectMany projections into a single operator. + /// Replaces .SelectMany(a).SelectMany(b) (2 operator allocations) with 1. + /// + /// The source element type. + /// The intermediate element type. + /// The final result type. + /// The source observable. + /// First projection: source → intermediate observable. + /// Second projection: intermediate → result observable. + /// A fused two-stage SelectMany observable. + public static IObservable SelectManyThen( + this IObservable source, + Func> first, + Func> second) => + new SelectManyThenObservable(source, first, second); + + /// + /// Runs a list of one-shot sequentially and emits + /// a single when all have completed. Replaces + /// .Concat().LastOrDefaultAsync() with a single operator that avoids stack + /// overflow on inline-completing sources. + /// + /// The observables to run in order. + /// A one-shot observable that completes after all sources. + public static IObservable RunAll(this IReadOnlyList> sources) => + new RunAllObservable(sources); + + /// + /// Walks a list of candidate keys sequentially, projects each into a one-shot + /// observable, transforms the raw value, and emits the first transformed value + /// that satisfies . Errors from individual projections + /// are swallowed (the candidate is skipped). If no candidate matches, emits + /// . + /// + /// The candidate key type. + /// The raw element type emitted by the projection. + /// The transformed result type. + /// The ordered list of candidate keys to walk. + /// Projects a key into a one-shot observable of raw values. + /// Transform applied to each raw value to produce the result. + /// Returns for a matching transformed value. + /// Value emitted when no candidate matches. + /// An observable emitting the first matching transformed value, or . + public static IObservable FirstMatchFromCandidates( + this IReadOnlyList candidates, + Func> project, + Func transform, + Func predicate, + TResult fallback) => + new FirstMatchFromCandidatesObservable( + candidates, + project, + transform, + predicate, + fallback); +} diff --git a/src/ReactiveUI.Primitives.Extensions/ReactiveUI.Primitives.Extensions.csproj b/src/ReactiveUI.Primitives.Extensions/ReactiveUI.Primitives.Extensions.csproj new file mode 100644 index 0000000..9d748b9 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/ReactiveUI.Primitives.Extensions.csproj @@ -0,0 +1,45 @@ + + + + $(LibraryTargetFrameworks) + enable + enable + preview + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + + + + + + + + + + diff --git a/src/ReactiveUI.Primitives.Extensions/Stale.cs b/src/ReactiveUI.Primitives.Extensions/Stale.cs new file mode 100644 index 0000000..69c73b8 --- /dev/null +++ b/src/ReactiveUI.Primitives.Extensions/Stale.cs @@ -0,0 +1,43 @@ +// 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.Primitives.Extensions; + +/// +/// Represents either a staleness indicator or a value update from an observable stream. Value-type shape; +/// the stale-detection operator emits these directly so per-emission allocations are zero. Note that +/// default(Stale<T>) represents a value update with the default ; use +/// new Stale<T>() to construct a staleness signal. +/// +/// The type of the update value. +public readonly record struct Stale : IStale +{ + /// The update value, or when the instance is a stale signal. + private readonly T? _update; + + /// + /// Initializes a new instance of the struct representing a stale signal. + /// + public Stale() + { + IsStale = true; + _update = default; + } + + /// + /// Initializes a new instance of the struct representing a value update. + /// + /// The update value. + public Stale(T? update) + { + IsStale = false; + _update = update; + } + + /// + public bool IsStale { get; } + + /// + public T? Update => IsStale ? throw new InvalidOperationException("Stale instance has no update.") : _update; +} diff --git a/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs b/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs index d1a8b6d..a72c967 100644 --- a/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs +++ b/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs @@ -9,7 +9,7 @@ namespace ReactiveUI.Primitives.Concurrency; /// /// WPF dispatcher sequencer that coalesces scheduled work onto a dispatcher drain. /// -/// +/// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public class DispatcherSequencer : DispatchSequencerBase { diff --git a/src/ReactiveUI.Primitives.slnx b/src/ReactiveUI.Primitives.slnx index cdd9bb3..c95f245 100644 --- a/src/ReactiveUI.Primitives.slnx +++ b/src/ReactiveUI.Primitives.slnx @@ -19,6 +19,7 @@ + @@ -30,6 +31,7 @@ + diff --git a/src/ReactiveUI.Primitives/Disposables/ActionDisposable.cs b/src/ReactiveUI.Primitives/Disposables/ActionDisposable.cs new file mode 100644 index 0000000..a47f3df --- /dev/null +++ b/src/ReactiveUI.Primitives/Disposables/ActionDisposable.cs @@ -0,0 +1,39 @@ +// 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.Primitives.Disposables; + +/// +/// An that runs the supplied exactly once on +/// . Replaces Disposable.Create(Action). +/// +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed class ActionDisposable : IsDisposed +{ + /// + /// The action to invoke once on dispose. + /// + private Action? _action; + + /// + /// Initializes a new instance of the class. + /// + /// The action to invoke once on dispose. + public ActionDisposable(Action action) => _action = action; + + /// + /// Gets a value indicating whether this instance has been disposed. + /// + public bool IsDisposed => Volatile.Read(ref _action) is null; + + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + + /// + public void Dispose() => Interlocked.Exchange(ref _action, null)?.Invoke(); +} diff --git a/src/ReactiveUI.Primitives/Disposables/AnonymousDisposable.cs b/src/ReactiveUI.Primitives/Disposables/AnonymousDisposable.cs deleted file mode 100644 index 2d9480e..0000000 --- a/src/ReactiveUI.Primitives/Disposables/AnonymousDisposable.cs +++ /dev/null @@ -1,29 +0,0 @@ -// 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.Primitives.Disposables; - -/// -/// Represents an Action-based disposable. -/// -public sealed class AnonymousDisposable : IDisposable -{ - /// - /// Disposal action, cleared after the first dispose call. - /// - private volatile Action? _dispose; - - /// - /// Initializes a new instance of the class. - /// - /// The dispose. - public AnonymousDisposable(Action dispose) => - _dispose = dispose; - - /// - /// Calls the disposal action if and only if the current instance hasn't been disposed yet. - /// - public void Dispose() => - Interlocked.Exchange(ref _dispose, null)?.Invoke(); -} \ No newline at end of file diff --git a/src/ReactiveUI.Primitives/Disposables/Disposable.cs b/src/ReactiveUI.Primitives/Disposables/Disposable.cs index 66a6538..8264706 100644 --- a/src/ReactiveUI.Primitives/Disposables/Disposable.cs +++ b/src/ReactiveUI.Primitives/Disposables/Disposable.cs @@ -12,7 +12,7 @@ public static class Disposable /// /// Gets the disposable that does nothing when disposed. /// - public static IDisposable Empty { get; } = new EmptyDisposable(); + public static IDisposable Empty { get; } = EmptyDisposable.Instance; /// /// Creates a disposable object that invokes the specified action when disposed. @@ -21,5 +21,5 @@ public static class Disposable /// The disposable object that runs the given action upon disposal. /// A action returns for backward compatibility with existing ReactiveUI.Primitives create pipelines. public static IDisposable Create(Action dispose) => - dispose == null ? Empty : new AnonymousDisposable(dispose); + dispose == null ? Empty : new ActionDisposable(dispose); } diff --git a/src/ReactiveUI.Primitives/Disposables/DisposableBag.cs b/src/ReactiveUI.Primitives/Disposables/DisposableBag.cs new file mode 100644 index 0000000..ca051c0 --- /dev/null +++ b/src/ReactiveUI.Primitives/Disposables/DisposableBag.cs @@ -0,0 +1,192 @@ +// 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.Primitives.Disposables; + +/// +/// A small composite-disposable replacement specialised for the common 2-slot +/// "subscription + sink" pair found throughout this codebase. Avoids the +/// backing field of +/// System.Reactive.Disposables.CompositeDisposable. +/// +/// +/// The first two added entries are stored inline. A third or later entry causes a fall-back +/// to a heap-allocated array. Disposal is idempotent and disposes every contained entry, +/// in registration order, exactly once. +/// +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed class DisposableBag : IsDisposed +{ + /// Starting capacity of the overflow array once the two inline slots are taken. + private const int OverflowInitialCapacity = 2; + + /// Growth factor for the overflow array. + private const int OverflowGrowthFactor = 2; + + /// + /// The synchronization object. + /// + private readonly Lock _gate = new(); + + /// + /// The first disposable slot. + /// + private IDisposable? _slot0; + + /// + /// The second disposable slot. + /// + private IDisposable? _slot1; + + /// + /// The overflow array for additional disposables. + /// + private IDisposable[]? _overflow; + + /// + /// The number of disposables in the overflow array. + /// + private int _overflowCount; + + /// + /// Indicates whether the bag has been disposed. + /// + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + public DisposableBag() + { + } + + /// + /// Initializes a new instance of the class with two pre-populated slots. + /// + /// The first disposable. + /// The second disposable. + public DisposableBag(IDisposable first, IDisposable second) + { + _slot0 = first; + _slot1 = second; + } + + /// + /// Initializes a new instance of the class with three pre-populated slots. + /// + /// The first disposable. + /// The second disposable. + /// The third disposable. + public DisposableBag(IDisposable first, IDisposable second, IDisposable third) + { + _slot0 = first; + _slot1 = second; + _overflow = new IDisposable[OverflowInitialCapacity]; + _overflow[0] = third; + _overflowCount = 1; + } + + /// + /// Gets a value indicating whether this instance has been disposed. + /// + public bool IsDisposed => Volatile.Read(ref _disposed); + + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + + /// + /// Adds a disposable to the bag. If the bag is already disposed, the supplied + /// disposable is disposed immediately. + /// + /// The disposable to add. + public void Add(IDisposable disposable) + { + if (disposable is null) + { + return; + } + + var disposeNow = false; + lock (_gate) + { + if (_disposed) + { + disposeNow = true; + } + else if (_slot0 is null) + { + _slot0 = disposable; + } + else if (_slot1 is null) + { + _slot1 = disposable; + } + else + { + if (_overflow is null) + { + _overflow = new IDisposable[OverflowInitialCapacity]; + } + else if (_overflowCount == _overflow.Length) + { + var grown = new IDisposable[_overflow.Length * OverflowGrowthFactor]; + Array.Copy(_overflow, grown, _overflowCount); + _overflow = grown; + } + + _overflow[_overflowCount++] = disposable; + } + } + + if (!disposeNow) + { + return; + } + + disposable.Dispose(); + } + + /// + public void Dispose() + { + IDisposable? s0; + IDisposable? s1; + IDisposable[]? overflow; + int overflowCount; + + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + s0 = _slot0; + s1 = _slot1; + overflow = _overflow; + overflowCount = _overflowCount; + _slot0 = null; + _slot1 = null; + _overflow = null; + _overflowCount = 0; + } + + s0?.Dispose(); + s1?.Dispose(); + if (overflow is null) + { + return; + } + + for (var i = 0; i < overflowCount; i++) + { + overflow[i].Dispose(); + } + } +} diff --git a/src/ReactiveUI.Primitives/Disposables/DisposableSlotHelper.cs b/src/ReactiveUI.Primitives/Disposables/DisposableSlotHelper.cs new file mode 100644 index 0000000..5ce5288 --- /dev/null +++ b/src/ReactiveUI.Primitives/Disposables/DisposableSlotHelper.cs @@ -0,0 +1,116 @@ +// 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.Primitives.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 RxVoid 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 the library's throw-helper methods. +/// +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 RxVoid 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.Primitives/Disposables/EmptyDisposable.cs b/src/ReactiveUI.Primitives/Disposables/EmptyDisposable.cs index d853530..7ab5677 100644 --- a/src/ReactiveUI.Primitives/Disposables/EmptyDisposable.cs +++ b/src/ReactiveUI.Primitives/Disposables/EmptyDisposable.cs @@ -5,10 +5,30 @@ namespace ReactiveUI.Primitives.Disposables; /// -/// Disposable that performs no action. +/// A no-op singleton used in place of Disposable.Empty. /// +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class EmptyDisposable : IDisposable { + /// + /// Prevents a default instance of the class from being created. + /// + private EmptyDisposable() + { + } + + /// + /// Gets the shared singleton instance. + /// + public static EmptyDisposable Instance { get; } = new(); + + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + /// public void Dispose() { diff --git a/src/ReactiveUI.Primitives/Disposables/MutableDisposable.cs b/src/ReactiveUI.Primitives/Disposables/MutableDisposable.cs new file mode 100644 index 0000000..29713e0 --- /dev/null +++ b/src/ReactiveUI.Primitives/Disposables/MutableDisposable.cs @@ -0,0 +1,43 @@ +// 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.Primitives.Disposables; + +/// +/// A disposable holder whose inner disposable can be re-assigned. The previous inner +/// disposable is NOT disposed when replaced (in contrast to ). +/// Once this object is disposed, any subsequently assigned inner disposable is disposed +/// immediately. Replaces MultipleAssignmentDisposable. +/// +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed class MutableDisposable : IsDisposed +{ + /// The current inner disposable. + private IDisposable? _current; + + /// Indicates whether the object has been disposed (0 = open, 1 = disposed). + private int _disposed; + + /// + /// Gets a value indicating whether this instance has been disposed. + /// + public bool IsDisposed => Volatile.Read(ref _disposed) == DisposableSlotHelper.DisposedSentinel; + + /// Gets or sets the current inner disposable. + public IDisposable? Disposable + { + get => Volatile.Read(ref _current); + set => DisposableSlotHelper.AssignWithoutDisposingPrevious(ref _current, ref _disposed, value); + } + + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + + /// + public void Dispose() => DisposableSlotHelper.TryDispose(ref _current, ref _disposed); +} diff --git a/src/ReactiveUI.Primitives/Disposables/OnceDisposable.cs b/src/ReactiveUI.Primitives/Disposables/OnceDisposable.cs new file mode 100644 index 0000000..504d181 --- /dev/null +++ b/src/ReactiveUI.Primitives/Disposables/OnceDisposable.cs @@ -0,0 +1,83 @@ +// 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.Primitives.Disposables; + +/// +/// A disposable holder whose inner disposable can be set exactly once. +/// Replaces SingleAssignmentDisposable. Subsequent assignments throw +/// ; if the holder has been disposed before +/// assignment, the supplied disposable is disposed immediately and no exception is thrown. +/// +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed class OnceDisposable : IsDisposed +{ + /// + /// Sentinel value indicating the object has been disposed. + /// + private static readonly IDisposable DisposedSentinel = EmptyDisposable.Instance; + + /// + /// The current inner disposable. + /// + private IDisposable? _current; + + /// + /// Gets a value indicating whether a disposable has been assigned. + /// + public bool IsAssigned => Volatile.Read(ref _current) is not null; + + /// + /// Gets a value indicating whether this instance has been disposed. + /// + public bool IsDisposed => ReferenceEquals(Volatile.Read(ref _current), DisposedSentinel); + + /// + /// Gets or sets the inner disposable. Setting more than once throws. + /// + public IDisposable? Disposable + { + get + { + var current = Volatile.Read(ref _current); + return ReferenceEquals(current, DisposedSentinel) ? null : current; + } + + set + { + var previous = Interlocked.CompareExchange(ref _current, value, null); + if (previous is null) + { + return; + } + + if (ReferenceEquals(previous, DisposedSentinel)) + { + value?.Dispose(); + return; + } + + throw new InvalidOperationException("Disposable already assigned."); + } + } + + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + + /// + public void Dispose() + { + var previous = Interlocked.Exchange(ref _current, DisposedSentinel); + if (previous is null || ReferenceEquals(previous, DisposedSentinel)) + { + return; + } + + previous.Dispose(); + } +} diff --git a/src/ReactiveUI.Primitives/Disposables/SwapDisposable.cs b/src/ReactiveUI.Primitives/Disposables/SwapDisposable.cs new file mode 100644 index 0000000..67229ae --- /dev/null +++ b/src/ReactiveUI.Primitives/Disposables/SwapDisposable.cs @@ -0,0 +1,43 @@ +// 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.Primitives.Disposables; + +/// +/// A disposable holder whose inner disposable can be re-assigned. The previous inner +/// disposable is disposed when replaced (in contrast to ). +/// Once this object is disposed, any subsequently assigned inner disposable is disposed +/// immediately. Replaces SerialDisposable. +/// +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed class SwapDisposable : IsDisposed +{ + /// The current inner disposable. + private IDisposable? _current; + + /// Indicates whether the object has been disposed (0 = open, 1 = disposed). + private int _disposed; + + /// + /// Gets a value indicating whether this instance has been disposed. + /// + public bool IsDisposed => Volatile.Read(ref _disposed) == DisposableSlotHelper.DisposedSentinel; + + /// Gets or sets the current inner disposable. Setting disposes the previous value. + public IDisposable? Disposable + { + get => Volatile.Read(ref _current); + set => DisposableSlotHelper.SwapAndDisposePrevious(ref _current, ref _disposed, value); + } + + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + + /// + public void Dispose() => DisposableSlotHelper.TryDispose(ref _current, ref _disposed); +} diff --git a/src/ReactiveUI.Primitives/ReactiveUI.Primitives.csproj b/src/ReactiveUI.Primitives/ReactiveUI.Primitives.csproj index 8c50554..bb75464 100644 --- a/src/ReactiveUI.Primitives/ReactiveUI.Primitives.csproj +++ b/src/ReactiveUI.Primitives/ReactiveUI.Primitives.csproj @@ -4,26 +4,6 @@ $(LibraryTargetFrameworks) - - 15.0 - - - - 15.0 - - - - 12.0 - - - - 15.0 - - - - 23.0 - - @@ -39,33 +19,20 @@ + - - - - - - - + + - - + + + diff --git a/src/ReactiveUI.Primitives/SignalOperatorParityMixins.Helpers.cs b/src/ReactiveUI.Primitives/SignalOperatorParityMixins.Helpers.cs index 41abf54..d6f7a1a 100644 --- a/src/ReactiveUI.Primitives/SignalOperatorParityMixins.Helpers.cs +++ b/src/ReactiveUI.Primitives/SignalOperatorParityMixins.Helpers.cs @@ -522,7 +522,7 @@ public IDisposable Subscribe(IObserver observer) return _sequencer.Schedule( (Observer: observer, Range: _range), _dueTime, - static (_, state) => EmitShiftedRange(state.Observer, state.Range)); + static (_, state) => EmitShiftedRange(state.Observer, state.Range)); } /// @@ -541,7 +541,7 @@ public IDisposable Subscribe(Action onNext, Action onError, Action return _sequencer.Schedule( (OnNext: onNext, OnCompleted: onCompleted, Range: _range), _dueTime, - static (_, state) => EmitShiftedRange(state.OnNext, state.OnCompleted, state.Range)); + static (_, state) => EmitShiftedRange(state.OnNext, state.OnCompleted, state.Range)); } } diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.cs new file mode 100644 index 0000000..e99ea5c --- /dev/null +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.cs @@ -0,0 +1,243 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using BenchmarkDotNet.Attributes; +using ReactiveUI.Primitives.Signals; +using PackageExtensions = ReactiveUI.Extensions.ReactiveExtensions; +using PrimitivesExtensions = ReactiveUI.Primitives.Extensions.ReactiveExtensions; +using RxObservable = System.Reactive.Linq.Observable; + +namespace ReactiveUI.Primitives.Benchmarks; + +/// +/// Benchmarks the synchronous extension-operator package against ReactiveUI.Extensions 4.0.0. +/// +[MemoryDiagnoser] +public class ReactiveExtensionsComparisonBenchmarks +{ + /// + /// The number of values produced by range-based benchmarks. + /// + private const int Count = 32; + + /// + /// Source array used by FromArray/FastForEach style benchmarks. + /// + private static readonly int[] Values = CreateValues(); + + /// + /// Source characters used by delimiter-buffer benchmarks. + /// + private static readonly char[] BufferCharacters = "xx[abc]yy[de]".ToCharArray(); + + /// + /// Source booleans used by boolean extension benchmarks. + /// + private static readonly bool[] BooleanValues = [true, false, false, true, false, true, false, false]; + + /// + /// Compares the fused filter/projection operator over a finite range. + /// + /// The observed value total. + [Benchmark(Baseline = true)] + public int PrimitivesWhereSelectRange() + { + var observer = new IntSignalObserver(); + using var subscription = PrimitivesExtensions.WhereSelect( + Signal.Sequence(0, Count), + static value => (value & 1) == 0, + static value => value * 3) + .Subscribe(observer); + return observer.Total; + } + + /// + /// Compares the ReactiveUI.Extensions 4.0.0 fused filter/projection operator over a finite range. + /// + /// The observed value total. + [Benchmark] + public int PackageWhereSelectRange() + { + var observer = new IntSignalObserver(); + using var subscription = PackageExtensions.WhereSelect( + RxObservable.Range(0, Count), + static value => (value & 1) == 0, + static value => value * 3) + .Subscribe(observer); + return observer.Total; + } + + /// + /// Compares the array-to-observable helper over an integer array. + /// + /// The observed value total. + [Benchmark] + public int PrimitivesFromArraySubscribe() + { + var observer = new IntSignalObserver(); + using var subscription = PrimitivesExtensions.FromArray(Values).Subscribe(observer); + return observer.Total; + } + + /// + /// Compares the ReactiveUI.Extensions 4.0.0 array-to-observable helper. + /// + /// The observed value total. + [Benchmark] + public int PackageFromArraySubscribe() + { + var observer = new IntSignalObserver(); + using var subscription = PackageExtensions.FromArray(Values).Subscribe(observer); + return observer.Total; + } + + /// + /// Compares the pairwise operator over a finite range. + /// + /// The aggregate of observed pair values. + [Benchmark] + public int PrimitivesPairwiseRange() + { + var observer = new PairObserver(); + using var subscription = PrimitivesExtensions.Pairwise(Signal.Sequence(0, Count)).Subscribe(observer); + return observer.Total; + } + + /// + /// Compares the ReactiveUI.Extensions 4.0.0 pairwise operator over a finite range. + /// + /// The aggregate of observed pair values. + [Benchmark] + public int PackagePairwiseRange() + { + var observer = new PairObserver(); + using var subscription = PackageExtensions.Pairwise(RxObservable.Range(0, Count)).Subscribe(observer); + return observer.Total; + } + + /// + /// Compares the delimiter-buffer operator over a finite character stream. + /// + /// The total length of emitted buffers. + [Benchmark] + public int PrimitivesBufferUntil() + { + var observer = new StringLengthObserver(); + using var subscription = PrimitivesExtensions.BufferUntil( + PrimitivesExtensions.FromArray(BufferCharacters), + '[', + ']') + .Subscribe(observer); + return observer.TotalLength; + } + + /// + /// Compares the ReactiveUI.Extensions 4.0.0 delimiter-buffer operator. + /// + /// The total length of emitted buffers. + [Benchmark] + public int PackageBufferUntil() + { + var observer = new StringLengthObserver(); + using var subscription = PackageExtensions.BufferUntil( + PackageExtensions.FromArray(BufferCharacters), + '[', + ']') + .Subscribe(observer); + return observer.TotalLength; + } + + /// + /// Compares the boolean negation operator over a finite stream. + /// + /// The number of true values emitted after negation. + [Benchmark] + public int PrimitivesNotWhereTrue() + { + var observer = new BoolSignalObserver(); + using var subscription = PrimitivesExtensions.WhereTrue( + PrimitivesExtensions.Not( + PrimitivesExtensions.FromArray(BooleanValues))) + .Subscribe(observer); + return observer.Total; + } + + /// + /// Compares the ReactiveUI.Extensions 4.0.0 boolean negation and true-filter operators. + /// + /// The number of true values emitted after negation. + [Benchmark] + public int PackageNotWhereTrue() + { + var observer = new BoolSignalObserver(); + using var subscription = PackageExtensions.WhereTrue( + PackageExtensions.Not( + PackageExtensions.FromArray(BooleanValues))) + .Subscribe(observer); + return observer.Total; + } + + /// + /// Creates the shared source array. + /// + /// An array containing values 0..31. + private static int[] CreateValues() + { + var values = new int[Count]; + for (var i = 0; i < values.Length; i++) + { + values[i] = i; + } + + return values; + } + + /// + /// Observer that aggregates pairwise tuple values. + /// + private sealed class PairObserver : IObserver<(int Previous, int Current)> + { + /// + /// Gets the aggregate total. + /// + public int Total { get; private set; } + + /// + public void OnNext((int Previous, int Current) value) => Total += value.Previous + value.Current; + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } + } + + /// + /// Observer that aggregates emitted string lengths. + /// + private sealed class StringLengthObserver : IObserver + { + /// + /// Gets the combined string length. + /// + public int TotalLength { get; private set; } + + /// + public void OnNext(string value) => TotalLength += value.Length; + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } + } +} diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveUI.Primitives.Benchmarks.csproj b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveUI.Primitives.Benchmarks.csproj index 3c6ac81..863a884 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveUI.Primitives.Benchmarks.csproj +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveUI.Primitives.Benchmarks.csproj @@ -20,6 +20,7 @@ + diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.EnumerableRest.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.EnumerableRest.cs index 1ea3742..5f787c9 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.EnumerableRest.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.EnumerableRest.cs @@ -323,7 +323,7 @@ public async Task WhenCombineLatestEnumerableDisposedDuringSubscribeLoop_ThenRet // First source triggers disposal when subscribed var disposeTrigger = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var slowSource = SignalAsync.Create(async (_, ct) => + var slowSource = AsyncObs.Create(async (_, ct) => { var disp = await disposeTrigger.Task.WaitAsync(ct); await disp.DisposeAsync(); @@ -365,7 +365,7 @@ public async Task WhenCombineLatestEnumerableFirstSourceCompletesImmediately_The var secondSourceSubscribed = false; // Second source records whether it was ever subscribed. - var trackingSource = SignalAsync.Create((_, _) => + var trackingSource = AsyncObs.Create((_, _) => { secondSourceSubscribed = true; return new(DisposableAsync.Empty); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Concat.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Concat.cs index cf9d774..7ed779c 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Concat.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Concat.cs @@ -789,7 +789,7 @@ public async Task WhenConcatObservablesHandleAlreadyDisposedWithoutFailure_ThenN public async Task WhenConcatObservablesInnerFails_ThenErrorPropagated() { var error = new InvalidOperationException("obs-concat-fail"); - var outer = SignalAsync.Return>(SignalAsync.Throw(error)); + var outer = SignalAsync.Return(SignalAsync.Throw(error)); await Assert.ThrowsAsync(async () => await outer.Concat().FirstAsync()); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Switch.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Switch.cs index 853b793..d797aaa 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Switch.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Switch.cs @@ -496,7 +496,7 @@ public async Task WhenSwitchInnerSubscriptionThrows_ThenCompletesWithFailure() [Test] public async Task WhenSwitchNewInnerArrives_ThenEmitsFromLatest() { - var result = await SignalAsync.Return>(SignalAsync.Return(42)) + var result = await SignalAsync.Return(SignalAsync.Return(42)) .Switch() .FirstAsync(); @@ -525,7 +525,7 @@ public async Task WhenSwitchSubscribedWithAlreadyCancelledToken_ThenSubscription using var cts = new CancellationTokenSource(); await cts.CancelAsync(); - await using var sub = await SignalAsync.Return>(SignalAsync.Return(1)) + await using var sub = await SignalAsync.Return(SignalAsync.Return(1)) .Switch() .SubscribeAsync(static (_, _) => default, cts.Token); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ErrorHandlingOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ErrorHandlingOperatorTests.cs index a7157fa..7b69a90 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/ErrorHandlingOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ErrorHandlingOperatorTests.cs @@ -342,7 +342,7 @@ public async Task WhenCatchHandlerThrows_ThenCompletesWithHandlerException() var source = SignalAsync.Throw(new InvalidOperationException("source error")); await using var sub = await source - .Catch(_ => throw new ArithmeticException("handler error")) + .Catch(_ => throw new ArithmeticException("handler error")) .SubscribeAsync( (_, _) => default, null, diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ReactiveUI.Primitives.Async.Tests.csproj b/src/tests/ReactiveUI.Primitives.Async.Tests/ReactiveUI.Primitives.Async.Tests.csproj index a642e8b..3898734 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/ReactiveUI.Primitives.Async.Tests.csproj +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ReactiveUI.Primitives.Async.Tests.csproj @@ -15,6 +15,7 @@ + diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/SignalTests.MappedAndConcurrent.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/SignalTests.MappedAndConcurrent.cs index 02c762c..f4f38ef 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/SignalTests.MappedAndConcurrent.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/SignalTests.MappedAndConcurrent.cs @@ -557,7 +557,7 @@ public async Task WhenForwardOnCompletedConcurrentlyWithEmptyObservers_ThenCompl { var emptyObservers = ImmutableArray>.Empty; - var task = Concurrent.ForwardOnCompletedConcurrently(emptyObservers, Result.Success); + var task = Concurrent.ForwardOnCompletedConcurrently(emptyObservers, Result.Success); await Assert.That(task.IsCompletedSuccessfully).IsTrue(); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/TimeBasedOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/TimeBasedOperatorTests.cs index 29aa1f3..eda3dbd 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/TimeBasedOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/TimeBasedOperatorTests.cs @@ -977,7 +977,7 @@ public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSp /// /// A timer that performs no operations. Used as the return value from - /// . + /// . /// private sealed class NoOpTimer : ITimer { diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/UnhandledExceptionTestExecutor.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/UnhandledExceptionTestExecutor.cs index d270d8a..024a48b 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/UnhandledExceptionTestExecutor.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/UnhandledExceptionTestExecutor.cs @@ -2,8 +2,8 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -[assembly: TestExecutorAttribute] -[assembly: NotInParallelAttribute] +[assembly: TestExecutor] +[assembly: NotInParallel] namespace ReactiveUI.Primitives.Async.Tests; diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/AotSafeAssertionExtensions.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/AotSafeAssertionExtensions.cs new file mode 100644 index 0000000..d86da73 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/AotSafeAssertionExtensions.cs @@ -0,0 +1,32 @@ +// 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 TUnit.Assertions.Conditions; +using TUnit.Assertions.Core; +using TUnit.Assertions.Enums; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// +/// AOT-safe collection-equality helpers. +/// +internal static class AotSafeAssertionExtensions +{ + /// + /// Asserts the collection is equivalent to + /// using the element type's default + /// (order-insensitive, mirroring IsEquivalentTo's default + /// ). + /// + /// The collection type being asserted. + /// The element type. + /// The assertion source. + /// The expected element sequence. + /// The chained collection-equivalency assertion. + public static IsEquivalentToAssertion IsCollectionEqualTo( + this IAssertionSource source, + IEnumerable expected) + where TCollection : IEnumerable + => source.IsEquivalentTo(expected, EqualityComparer.Default); +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ApiApprovalTests.Extensions.DotNet10_0.verified.txt b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ApiApprovalTests.Extensions.DotNet10_0.verified.txt new file mode 100644 index 0000000..73d4ad7 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ApiApprovalTests.Extensions.DotNet10_0.verified.txt @@ -0,0 +1,229 @@ +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Extensions.Tests")] +namespace ReactiveUI.Primitives.Extensions +{ + public class Continuation : System.IDisposable + { + public Continuation() { } + public long CompletedPhases { get; } + public void Dispose() { } + protected virtual void Dispose(bool disposing) { } + public System.Threading.Tasks.Task Lock(T item, [System.Runtime.CompilerServices.TupleElementNames(new string[] { + "value", + "Sync"})] System.IObserver>? observer) { } + public System.Threading.Tasks.ValueTask LockValueTask(T item, [System.Runtime.CompilerServices.TupleElementNames(new string[] { + "value", + "Sync"})] System.IObserver>? observer) { } + } + public readonly struct Heartbeat : ReactiveUI.Primitives.Extensions.IHeartbeat, System.IEquatable> + { + public Heartbeat() { } + public Heartbeat(T? update) { } + public bool IsHeartbeat { get; } + public T Update { get; } + } + public interface IHeartbeat + { + bool IsHeartbeat { get; } + T Update { get; } + } + public interface IStale + { + bool IsStale { get; } + T Update { get; } + } + public static class ObservableSubscriptionExtensions + { + public static void SubscribeAndComplete(this System.IObservable source) { } + public static System.Exception? SubscribeGetError(this System.IObservable source) { } + public static System.Exception? SubscribeGetError(this System.IObservable source) { } + public static T? SubscribeGetValue(this System.IObservable source) { } + public static void WaitForCompletion(this System.IObservable source) { } + public static void WaitForCompletion(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static void WaitForCompletion(this System.IObservable source, System.TimeSpan timeout) { } + public static void WaitForCompletion(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.TimeSpan timeout) { } + public static System.Exception? WaitForError(this System.IObservable source) { } + public static System.Exception? WaitForError(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.Exception? WaitForError(this System.IObservable source, System.TimeSpan timeout) { } + public static System.Exception? WaitForError(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.TimeSpan timeout) { } + public static T? WaitForValue(this System.IObservable source) { } + public static T? WaitForValue(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static T? WaitForValue(this System.IObservable source, System.TimeSpan timeout) { } + public static T? WaitForValue(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.TimeSpan timeout) { } + } + public static class Observables + { + public static System.IObservable Return(T value) { } + } + public static class ObserverExtensions + { + public static void FastForEach(this System.IObserver observer, System.Collections.Generic.IEnumerable source) { } + } + public static class ReactiveExtensions + { + public static System.IObservable AsSignal(this System.IObservable observable) { } + public static System.IObservable BufferUntil(this System.IObservable @this, char startsWith, char endsWith) { } + public static System.IObservable> BufferUntilIdle(this System.IObservable source, System.TimeSpan idleTime) { } + public static System.IObservable> BufferUntilIdle(this System.IObservable source, System.TimeSpan idleTime, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable> BufferUntilInactive(this System.IObservable source, System.TimeSpan inactivityPeriod) { } + public static System.IObservable> BufferUntilInactive(this System.IObservable source, System.TimeSpan inactivityPeriod, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable CatchAndReturn(this System.IObservable source, T fallback) { } + public static System.IObservable CatchAndReturn(this System.IObservable source, System.Func fallbackFactory) + where TException : System.Exception { } + public static System.IObservable CatchIgnore(this System.IObservable source) { } + public static System.IObservable CatchIgnore(this System.IObservable source, System.Action errorAction) + where TException : System.Exception { } + public static System.IObservable CatchReturn(this System.IObservable source, T fallback) { } + public static System.IObservable CatchReturnUnit(this System.IObservable source) { } + public static System.IObservable CombineLatestValuesAreAllFalse(this System.Collections.Generic.IEnumerable> sources) { } + public static System.IObservable CombineLatestValuesAreAllTrue(this System.Collections.Generic.IEnumerable> sources) { } + public static System.IObservable Conflate(this System.IObservable source, System.TimeSpan minimumUpdatePeriod, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable DebounceImmediate(this System.IObservable source, System.TimeSpan dueTime) { } + public static System.IObservable DebounceImmediate(this System.IObservable source, System.TimeSpan dueTime, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable DebounceUntil(this System.IObservable source, System.TimeSpan debounce, System.Func condition) { } + public static System.IObservable DebounceUntil(this System.IObservable source, System.TimeSpan debounce, System.Func condition, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable> DetectStale(this System.IObservable source, System.TimeSpan stalenessPeriod, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable DoOnDispose(this System.IObservable source, System.Action disposeAction) { } + public static System.IObservable DoOnSubscribe(this System.IObservable source, System.Action action) { } + public static System.IObservable DropIfBusy(this System.IObservable source, System.Func asyncAction) { } + public static System.IObservable Filter(this System.IObservable source, System.Text.RegularExpressions.Regex regex) { } + public static System.IObservable Filter(this System.IObservable source, string regexPattern) { } + public static System.IObservable FirstMatchFromCandidates(this System.Collections.Generic.IReadOnlyList candidates, System.Func> project, System.Func transform, System.Func predicate, TResult fallback) { } + public static System.IObservable ForEach(this System.IObservable> source) { } + public static System.IObservable ForEach(this System.IObservable> source, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable FromArray(this System.Collections.Generic.IEnumerable source) { } + public static System.IObservable FromArray(this System.Collections.Generic.IEnumerable source, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable GetMax(this System.IObservable @this, params System.IObservable[] sources) + where T : struct, System.IComparable { } + public static System.IObservable GetMin(this System.IObservable @this, params System.IObservable[] sources) + where T : struct, System.IComparable { } + public static System.IObservable> Heartbeat(this System.IObservable source, System.TimeSpan heartbeatPeriod, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable LatestOrDefault(this System.IObservable source, T defaultValue) { } + public static System.IObservable LogErrors(this System.IObservable source, System.Action logger) { } + public static System.IObservable Not(this System.IObservable source) { } + public static System.IObservable ObserveOnIf(this System.IObservable source, System.IObservable condition, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable ObserveOnIf(this System.IObservable source, bool condition, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable ObserveOnIf(this System.IObservable source, System.IObservable condition, ReactiveUI.Primitives.Concurrency.ISequencer trueScheduler, ReactiveUI.Primitives.Concurrency.ISequencer falseScheduler) { } + public static System.IObservable ObserveOnIf(this System.IObservable source, bool condition, ReactiveUI.Primitives.Concurrency.ISequencer trueScheduler, ReactiveUI.Primitives.Concurrency.ISequencer falseScheduler) { } + public static System.IObservable ObserveOnSafe(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable OnErrorRetry(this System.IObservable source) { } + public static System.IObservable OnErrorRetry(this System.IObservable source, System.Action onError) + where TException : System.Exception { } + public static System.IObservable OnErrorRetry(this System.IObservable source, System.Action onError, System.TimeSpan delay) + where TException : System.Exception { } + public static System.IObservable OnErrorRetry(this System.IObservable source, System.Action onError, int retryCount) + where TException : System.Exception { } + public static System.IObservable OnErrorRetry(this System.IObservable source, System.Action onError, int retryCount, System.TimeSpan delay) + where TException : System.Exception { } + public static System.IObservable OnErrorRetry(this System.IObservable source, System.Action onError, int retryCount, System.TimeSpan delay, ReactiveUI.Primitives.Concurrency.ISequencer delayScheduler) + where TException : System.Exception { } + public static void OnNext(this System.IObserver observer, params T[] events) { } + [return: System.Runtime.CompilerServices.TupleElementNames(new string[] { + "Previous", + "Current"})] + public static System.IObservable> Pairwise(this System.IObservable source) { } + [return: System.Runtime.CompilerServices.TupleElementNames(new string[] { + "True", + "False"})] + public static System.ValueTuple, System.IObservable> Partition(this System.IObservable source, System.Func predicate) { } + public static System.IObservable ReplayLastOnSubscribe(this System.IObservable source, T initialValue) { } + public static System.IObservable RetryForeverWithDelay(this System.IObservable source, System.TimeSpan delay) { } + public static System.IObservable RetryWithBackoff(this System.IObservable source, int maxRetries, System.TimeSpan initialDelay) { } + public static System.IObservable RetryWithBackoff(this System.IObservable source, int maxRetries, System.TimeSpan initialDelay, double backoffFactor, System.TimeSpan? maxDelay, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable RetryWithDelay(this System.IObservable source, int retryCount, System.Func delaySelector) { } + public static System.IObservable RetryWithFixedDelay(this System.IObservable source, int retryCount, System.TimeSpan delay) { } + public static System.IObservable RunAll(this System.Collections.Generic.IReadOnlyList> sources) { } + public static System.IObservable SampleLatest(this System.IObservable source, System.IObservable trigger) { } + public static System.IObservable ScanWithInitial(this System.IObservable source, TAccumulate initial, System.Func accumulator) { } + public static System.IObservable Schedule(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Func function) { } + public static System.IObservable Schedule(this System.IObservable source, System.DateTimeOffset dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable Schedule(this System.IObservable source, System.TimeSpan dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable Schedule(this T value, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Func function) { } + public static System.IObservable Schedule(this T value, System.DateTimeOffset dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable Schedule(this T value, System.TimeSpan dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable Schedule(this System.IObservable source, System.DateTimeOffset dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Action action) { } + public static System.IObservable Schedule(this System.IObservable source, System.TimeSpan dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Action action) { } + public static System.IObservable Schedule(this System.IObservable source, System.TimeSpan dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Func function) { } + public static System.IObservable Schedule(this T value, System.DateTimeOffset dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Action action) { } + public static System.IObservable Schedule(this T value, System.TimeSpan dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Action action) { } + public static System.IObservable Schedule(this T value, System.TimeSpan dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Func function) { } + public static System.IDisposable ScheduleSafe(this ReactiveUI.Primitives.Concurrency.ISequencer? scheduler, System.Action action) { } + public static System.IDisposable ScheduleSafe(this ReactiveUI.Primitives.Concurrency.ISequencer? scheduler, System.TimeSpan dueTime, System.Action action) { } + public static System.IObservable SelectAsync(this System.IObservable source, System.Func> asyncSelector) { } + public static System.IObservable SelectAsync(this System.IObservable source, System.Func> asyncSelector) { } + public static System.IObservable SelectAsyncConcurrent(this System.IObservable source, System.Func> selector, int maxConcurrency) { } + public static System.IObservable SelectAsyncSequential(this System.IObservable source, System.Func> selector) { } + public static System.IObservable SelectConstant(this System.IObservable source, TResult constant) { } + public static System.IObservable SelectLatestAsync(this System.IObservable source, System.Func> selector) { } + public static System.IObservable SelectManyThen(this System.IObservable source, System.Func> first, System.Func> second) { } + public static System.IObservable Shuffle(this System.IObservable source) { } + public static System.IObservable SkipWhileNull(this System.IObservable source) + where T : class { } + public static System.IObservable Start(System.Action action, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable Start(System.Func function, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IDisposable SubscribeAsync(this System.IObservable source, System.Func onNext) { } + public static System.IDisposable SubscribeAsync(this System.IObservable source, System.Func onNext, System.Action onCompleted) { } + public static System.IDisposable SubscribeAsync(this System.IObservable source, System.Func onNext, System.Action onError) { } + public static System.IDisposable SubscribeAsync(this System.IObservable source, System.Func onNext, System.Action onError, System.Action onCompleted) { } + public static System.IDisposable SubscribeSynchronous(this System.IObservable source, System.Func onNext) { } + public static System.IDisposable SubscribeSynchronous(this System.IObservable source, System.Func onNext, System.Action onCompleted) { } + public static System.IDisposable SubscribeSynchronous(this System.IObservable source, System.Func onNext, System.Action onError) { } + public static System.IDisposable SubscribeSynchronous(this System.IObservable source, System.Func onNext, System.Action onError, System.Action onCompleted) { } + public static System.IObservable SwitchIfEmpty(this System.IObservable source, System.IObservable fallback) { } + public static System.IObservable SyncTimer(System.TimeSpan timeSpan) { } + public static System.IObservable SyncTimer(System.TimeSpan timeSpan, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + [return: System.Runtime.CompilerServices.TupleElementNames(new string[] { + "Value", + "Sync"})] + public static System.IObservable> SynchronizeAsync(this System.IObservable source) { } + [return: System.Runtime.CompilerServices.TupleElementNames(new string[] { + "Value", + "Sync"})] + public static System.IObservable> SynchronizeSynchronous(this System.IObservable source) { } + public static System.IObservable TakeUntil(this System.IObservable source, System.Func predicate) { } + public static System.IObservable ThrottleDistinct(this System.IObservable source, System.TimeSpan throttle) { } + public static System.IObservable ThrottleDistinct(this System.IObservable source, System.TimeSpan throttle, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable ThrottleFirst(this System.IObservable source, System.TimeSpan window) { } + public static System.IObservable ThrottleFirst(this System.IObservable source, System.TimeSpan window, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable ThrottleOnScheduler(this System.IObservable source, System.TimeSpan timeSpan, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable ThrottleUntilTrue(this System.IObservable source, System.TimeSpan throttle, System.Func predicate) { } + public static System.Threading.Tasks.Task ToHotTask(this System.IObservable source) { } + public static System.Threading.Tasks.ValueTask ToHotValueTask(this System.IObservable source) { } + public static System.IObservable ToPropertyObservable(this T source, System.Linq.Expressions.Expression> propertyExpression) + where T : System.ComponentModel.INotifyPropertyChanged { } + [return: System.Runtime.CompilerServices.TupleElementNames(new string[] { + "Observable", + "Observer"})] + public static System.ValueTuple, System.IObserver> ToReadOnlyBehavior(T initialValue) { } + public static System.IObservable TrySelect(this System.IObservable source, System.Func selector) { } + public static System.IObservable Using(this T obj, System.Action? action) + where T : System.IDisposable { } + public static System.IObservable Using(this T obj, System.Action? action, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) + where T : System.IDisposable { } + public static System.IObservable Using(this T obj, System.Func function) + where T : System.IDisposable { } + public static System.IObservable Using(this T obj, System.Func function, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) + where T : System.IDisposable { } + public static System.IObservable WaitUntil(this System.IObservable source, System.Func predicate) { } + public static System.IObservable WhereFalse(this System.IObservable source) { } + public static System.IObservable WhereIsNotNull(this System.IObservable observable) { } + public static System.IObservable WhereSelect(this System.IObservable source, System.Func predicate, System.Func selector) { } + public static System.IObservable WhereTrue(this System.IObservable source) { } + public static System.IObservable While(System.Func condition, System.Action action) { } + public static System.IObservable While(System.Func condition, System.Action action, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable WithLimitedConcurrency(this System.Collections.Generic.IEnumerable> taskFunctions, int maxConcurrency) { } + } + public readonly struct Stale : ReactiveUI.Primitives.Extensions.IStale, System.IEquatable> + { + public Stale() { } + public Stale(T? update) { } + public bool IsStale { get; } + public T Update { get; } + } +} +namespace ReactiveUI.Primitives.Extensions.Operators +{ + public static class CachedObservables + { + public static System.IObservable UnitDefault { get; } + } +} \ No newline at end of file diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ApiApprovalTests.Extensions.DotNet8_0.verified.txt b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ApiApprovalTests.Extensions.DotNet8_0.verified.txt new file mode 100644 index 0000000..73d4ad7 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ApiApprovalTests.Extensions.DotNet8_0.verified.txt @@ -0,0 +1,229 @@ +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Extensions.Tests")] +namespace ReactiveUI.Primitives.Extensions +{ + public class Continuation : System.IDisposable + { + public Continuation() { } + public long CompletedPhases { get; } + public void Dispose() { } + protected virtual void Dispose(bool disposing) { } + public System.Threading.Tasks.Task Lock(T item, [System.Runtime.CompilerServices.TupleElementNames(new string[] { + "value", + "Sync"})] System.IObserver>? observer) { } + public System.Threading.Tasks.ValueTask LockValueTask(T item, [System.Runtime.CompilerServices.TupleElementNames(new string[] { + "value", + "Sync"})] System.IObserver>? observer) { } + } + public readonly struct Heartbeat : ReactiveUI.Primitives.Extensions.IHeartbeat, System.IEquatable> + { + public Heartbeat() { } + public Heartbeat(T? update) { } + public bool IsHeartbeat { get; } + public T Update { get; } + } + public interface IHeartbeat + { + bool IsHeartbeat { get; } + T Update { get; } + } + public interface IStale + { + bool IsStale { get; } + T Update { get; } + } + public static class ObservableSubscriptionExtensions + { + public static void SubscribeAndComplete(this System.IObservable source) { } + public static System.Exception? SubscribeGetError(this System.IObservable source) { } + public static System.Exception? SubscribeGetError(this System.IObservable source) { } + public static T? SubscribeGetValue(this System.IObservable source) { } + public static void WaitForCompletion(this System.IObservable source) { } + public static void WaitForCompletion(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static void WaitForCompletion(this System.IObservable source, System.TimeSpan timeout) { } + public static void WaitForCompletion(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.TimeSpan timeout) { } + public static System.Exception? WaitForError(this System.IObservable source) { } + public static System.Exception? WaitForError(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.Exception? WaitForError(this System.IObservable source, System.TimeSpan timeout) { } + public static System.Exception? WaitForError(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.TimeSpan timeout) { } + public static T? WaitForValue(this System.IObservable source) { } + public static T? WaitForValue(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static T? WaitForValue(this System.IObservable source, System.TimeSpan timeout) { } + public static T? WaitForValue(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.TimeSpan timeout) { } + } + public static class Observables + { + public static System.IObservable Return(T value) { } + } + public static class ObserverExtensions + { + public static void FastForEach(this System.IObserver observer, System.Collections.Generic.IEnumerable source) { } + } + public static class ReactiveExtensions + { + public static System.IObservable AsSignal(this System.IObservable observable) { } + public static System.IObservable BufferUntil(this System.IObservable @this, char startsWith, char endsWith) { } + public static System.IObservable> BufferUntilIdle(this System.IObservable source, System.TimeSpan idleTime) { } + public static System.IObservable> BufferUntilIdle(this System.IObservable source, System.TimeSpan idleTime, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable> BufferUntilInactive(this System.IObservable source, System.TimeSpan inactivityPeriod) { } + public static System.IObservable> BufferUntilInactive(this System.IObservable source, System.TimeSpan inactivityPeriod, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable CatchAndReturn(this System.IObservable source, T fallback) { } + public static System.IObservable CatchAndReturn(this System.IObservable source, System.Func fallbackFactory) + where TException : System.Exception { } + public static System.IObservable CatchIgnore(this System.IObservable source) { } + public static System.IObservable CatchIgnore(this System.IObservable source, System.Action errorAction) + where TException : System.Exception { } + public static System.IObservable CatchReturn(this System.IObservable source, T fallback) { } + public static System.IObservable CatchReturnUnit(this System.IObservable source) { } + public static System.IObservable CombineLatestValuesAreAllFalse(this System.Collections.Generic.IEnumerable> sources) { } + public static System.IObservable CombineLatestValuesAreAllTrue(this System.Collections.Generic.IEnumerable> sources) { } + public static System.IObservable Conflate(this System.IObservable source, System.TimeSpan minimumUpdatePeriod, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable DebounceImmediate(this System.IObservable source, System.TimeSpan dueTime) { } + public static System.IObservable DebounceImmediate(this System.IObservable source, System.TimeSpan dueTime, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable DebounceUntil(this System.IObservable source, System.TimeSpan debounce, System.Func condition) { } + public static System.IObservable DebounceUntil(this System.IObservable source, System.TimeSpan debounce, System.Func condition, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable> DetectStale(this System.IObservable source, System.TimeSpan stalenessPeriod, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable DoOnDispose(this System.IObservable source, System.Action disposeAction) { } + public static System.IObservable DoOnSubscribe(this System.IObservable source, System.Action action) { } + public static System.IObservable DropIfBusy(this System.IObservable source, System.Func asyncAction) { } + public static System.IObservable Filter(this System.IObservable source, System.Text.RegularExpressions.Regex regex) { } + public static System.IObservable Filter(this System.IObservable source, string regexPattern) { } + public static System.IObservable FirstMatchFromCandidates(this System.Collections.Generic.IReadOnlyList candidates, System.Func> project, System.Func transform, System.Func predicate, TResult fallback) { } + public static System.IObservable ForEach(this System.IObservable> source) { } + public static System.IObservable ForEach(this System.IObservable> source, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable FromArray(this System.Collections.Generic.IEnumerable source) { } + public static System.IObservable FromArray(this System.Collections.Generic.IEnumerable source, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable GetMax(this System.IObservable @this, params System.IObservable[] sources) + where T : struct, System.IComparable { } + public static System.IObservable GetMin(this System.IObservable @this, params System.IObservable[] sources) + where T : struct, System.IComparable { } + public static System.IObservable> Heartbeat(this System.IObservable source, System.TimeSpan heartbeatPeriod, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable LatestOrDefault(this System.IObservable source, T defaultValue) { } + public static System.IObservable LogErrors(this System.IObservable source, System.Action logger) { } + public static System.IObservable Not(this System.IObservable source) { } + public static System.IObservable ObserveOnIf(this System.IObservable source, System.IObservable condition, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable ObserveOnIf(this System.IObservable source, bool condition, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable ObserveOnIf(this System.IObservable source, System.IObservable condition, ReactiveUI.Primitives.Concurrency.ISequencer trueScheduler, ReactiveUI.Primitives.Concurrency.ISequencer falseScheduler) { } + public static System.IObservable ObserveOnIf(this System.IObservable source, bool condition, ReactiveUI.Primitives.Concurrency.ISequencer trueScheduler, ReactiveUI.Primitives.Concurrency.ISequencer falseScheduler) { } + public static System.IObservable ObserveOnSafe(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable OnErrorRetry(this System.IObservable source) { } + public static System.IObservable OnErrorRetry(this System.IObservable source, System.Action onError) + where TException : System.Exception { } + public static System.IObservable OnErrorRetry(this System.IObservable source, System.Action onError, System.TimeSpan delay) + where TException : System.Exception { } + public static System.IObservable OnErrorRetry(this System.IObservable source, System.Action onError, int retryCount) + where TException : System.Exception { } + public static System.IObservable OnErrorRetry(this System.IObservable source, System.Action onError, int retryCount, System.TimeSpan delay) + where TException : System.Exception { } + public static System.IObservable OnErrorRetry(this System.IObservable source, System.Action onError, int retryCount, System.TimeSpan delay, ReactiveUI.Primitives.Concurrency.ISequencer delayScheduler) + where TException : System.Exception { } + public static void OnNext(this System.IObserver observer, params T[] events) { } + [return: System.Runtime.CompilerServices.TupleElementNames(new string[] { + "Previous", + "Current"})] + public static System.IObservable> Pairwise(this System.IObservable source) { } + [return: System.Runtime.CompilerServices.TupleElementNames(new string[] { + "True", + "False"})] + public static System.ValueTuple, System.IObservable> Partition(this System.IObservable source, System.Func predicate) { } + public static System.IObservable ReplayLastOnSubscribe(this System.IObservable source, T initialValue) { } + public static System.IObservable RetryForeverWithDelay(this System.IObservable source, System.TimeSpan delay) { } + public static System.IObservable RetryWithBackoff(this System.IObservable source, int maxRetries, System.TimeSpan initialDelay) { } + public static System.IObservable RetryWithBackoff(this System.IObservable source, int maxRetries, System.TimeSpan initialDelay, double backoffFactor, System.TimeSpan? maxDelay, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable RetryWithDelay(this System.IObservable source, int retryCount, System.Func delaySelector) { } + public static System.IObservable RetryWithFixedDelay(this System.IObservable source, int retryCount, System.TimeSpan delay) { } + public static System.IObservable RunAll(this System.Collections.Generic.IReadOnlyList> sources) { } + public static System.IObservable SampleLatest(this System.IObservable source, System.IObservable trigger) { } + public static System.IObservable ScanWithInitial(this System.IObservable source, TAccumulate initial, System.Func accumulator) { } + public static System.IObservable Schedule(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Func function) { } + public static System.IObservable Schedule(this System.IObservable source, System.DateTimeOffset dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable Schedule(this System.IObservable source, System.TimeSpan dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable Schedule(this T value, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Func function) { } + public static System.IObservable Schedule(this T value, System.DateTimeOffset dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable Schedule(this T value, System.TimeSpan dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable Schedule(this System.IObservable source, System.DateTimeOffset dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Action action) { } + public static System.IObservable Schedule(this System.IObservable source, System.TimeSpan dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Action action) { } + public static System.IObservable Schedule(this System.IObservable source, System.TimeSpan dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Func function) { } + public static System.IObservable Schedule(this T value, System.DateTimeOffset dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Action action) { } + public static System.IObservable Schedule(this T value, System.TimeSpan dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Action action) { } + public static System.IObservable Schedule(this T value, System.TimeSpan dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Func function) { } + public static System.IDisposable ScheduleSafe(this ReactiveUI.Primitives.Concurrency.ISequencer? scheduler, System.Action action) { } + public static System.IDisposable ScheduleSafe(this ReactiveUI.Primitives.Concurrency.ISequencer? scheduler, System.TimeSpan dueTime, System.Action action) { } + public static System.IObservable SelectAsync(this System.IObservable source, System.Func> asyncSelector) { } + public static System.IObservable SelectAsync(this System.IObservable source, System.Func> asyncSelector) { } + public static System.IObservable SelectAsyncConcurrent(this System.IObservable source, System.Func> selector, int maxConcurrency) { } + public static System.IObservable SelectAsyncSequential(this System.IObservable source, System.Func> selector) { } + public static System.IObservable SelectConstant(this System.IObservable source, TResult constant) { } + public static System.IObservable SelectLatestAsync(this System.IObservable source, System.Func> selector) { } + public static System.IObservable SelectManyThen(this System.IObservable source, System.Func> first, System.Func> second) { } + public static System.IObservable Shuffle(this System.IObservable source) { } + public static System.IObservable SkipWhileNull(this System.IObservable source) + where T : class { } + public static System.IObservable Start(System.Action action, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable Start(System.Func function, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IDisposable SubscribeAsync(this System.IObservable source, System.Func onNext) { } + public static System.IDisposable SubscribeAsync(this System.IObservable source, System.Func onNext, System.Action onCompleted) { } + public static System.IDisposable SubscribeAsync(this System.IObservable source, System.Func onNext, System.Action onError) { } + public static System.IDisposable SubscribeAsync(this System.IObservable source, System.Func onNext, System.Action onError, System.Action onCompleted) { } + public static System.IDisposable SubscribeSynchronous(this System.IObservable source, System.Func onNext) { } + public static System.IDisposable SubscribeSynchronous(this System.IObservable source, System.Func onNext, System.Action onCompleted) { } + public static System.IDisposable SubscribeSynchronous(this System.IObservable source, System.Func onNext, System.Action onError) { } + public static System.IDisposable SubscribeSynchronous(this System.IObservable source, System.Func onNext, System.Action onError, System.Action onCompleted) { } + public static System.IObservable SwitchIfEmpty(this System.IObservable source, System.IObservable fallback) { } + public static System.IObservable SyncTimer(System.TimeSpan timeSpan) { } + public static System.IObservable SyncTimer(System.TimeSpan timeSpan, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + [return: System.Runtime.CompilerServices.TupleElementNames(new string[] { + "Value", + "Sync"})] + public static System.IObservable> SynchronizeAsync(this System.IObservable source) { } + [return: System.Runtime.CompilerServices.TupleElementNames(new string[] { + "Value", + "Sync"})] + public static System.IObservable> SynchronizeSynchronous(this System.IObservable source) { } + public static System.IObservable TakeUntil(this System.IObservable source, System.Func predicate) { } + public static System.IObservable ThrottleDistinct(this System.IObservable source, System.TimeSpan throttle) { } + public static System.IObservable ThrottleDistinct(this System.IObservable source, System.TimeSpan throttle, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable ThrottleFirst(this System.IObservable source, System.TimeSpan window) { } + public static System.IObservable ThrottleFirst(this System.IObservable source, System.TimeSpan window, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable ThrottleOnScheduler(this System.IObservable source, System.TimeSpan timeSpan, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable ThrottleUntilTrue(this System.IObservable source, System.TimeSpan throttle, System.Func predicate) { } + public static System.Threading.Tasks.Task ToHotTask(this System.IObservable source) { } + public static System.Threading.Tasks.ValueTask ToHotValueTask(this System.IObservable source) { } + public static System.IObservable ToPropertyObservable(this T source, System.Linq.Expressions.Expression> propertyExpression) + where T : System.ComponentModel.INotifyPropertyChanged { } + [return: System.Runtime.CompilerServices.TupleElementNames(new string[] { + "Observable", + "Observer"})] + public static System.ValueTuple, System.IObserver> ToReadOnlyBehavior(T initialValue) { } + public static System.IObservable TrySelect(this System.IObservable source, System.Func selector) { } + public static System.IObservable Using(this T obj, System.Action? action) + where T : System.IDisposable { } + public static System.IObservable Using(this T obj, System.Action? action, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) + where T : System.IDisposable { } + public static System.IObservable Using(this T obj, System.Func function) + where T : System.IDisposable { } + public static System.IObservable Using(this T obj, System.Func function, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) + where T : System.IDisposable { } + public static System.IObservable WaitUntil(this System.IObservable source, System.Func predicate) { } + public static System.IObservable WhereFalse(this System.IObservable source) { } + public static System.IObservable WhereIsNotNull(this System.IObservable observable) { } + public static System.IObservable WhereSelect(this System.IObservable source, System.Func predicate, System.Func selector) { } + public static System.IObservable WhereTrue(this System.IObservable source) { } + public static System.IObservable While(System.Func condition, System.Action action) { } + public static System.IObservable While(System.Func condition, System.Action action, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable WithLimitedConcurrency(this System.Collections.Generic.IEnumerable> taskFunctions, int maxConcurrency) { } + } + public readonly struct Stale : ReactiveUI.Primitives.Extensions.IStale, System.IEquatable> + { + public Stale() { } + public Stale(T? update) { } + public bool IsStale { get; } + public T Update { get; } + } +} +namespace ReactiveUI.Primitives.Extensions.Operators +{ + public static class CachedObservables + { + public static System.IObservable UnitDefault { get; } + } +} \ No newline at end of file diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ApiApprovalTests.Extensions.DotNet9_0.verified.txt b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ApiApprovalTests.Extensions.DotNet9_0.verified.txt new file mode 100644 index 0000000..73d4ad7 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ApiApprovalTests.Extensions.DotNet9_0.verified.txt @@ -0,0 +1,229 @@ +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Extensions.Tests")] +namespace ReactiveUI.Primitives.Extensions +{ + public class Continuation : System.IDisposable + { + public Continuation() { } + public long CompletedPhases { get; } + public void Dispose() { } + protected virtual void Dispose(bool disposing) { } + public System.Threading.Tasks.Task Lock(T item, [System.Runtime.CompilerServices.TupleElementNames(new string[] { + "value", + "Sync"})] System.IObserver>? observer) { } + public System.Threading.Tasks.ValueTask LockValueTask(T item, [System.Runtime.CompilerServices.TupleElementNames(new string[] { + "value", + "Sync"})] System.IObserver>? observer) { } + } + public readonly struct Heartbeat : ReactiveUI.Primitives.Extensions.IHeartbeat, System.IEquatable> + { + public Heartbeat() { } + public Heartbeat(T? update) { } + public bool IsHeartbeat { get; } + public T Update { get; } + } + public interface IHeartbeat + { + bool IsHeartbeat { get; } + T Update { get; } + } + public interface IStale + { + bool IsStale { get; } + T Update { get; } + } + public static class ObservableSubscriptionExtensions + { + public static void SubscribeAndComplete(this System.IObservable source) { } + public static System.Exception? SubscribeGetError(this System.IObservable source) { } + public static System.Exception? SubscribeGetError(this System.IObservable source) { } + public static T? SubscribeGetValue(this System.IObservable source) { } + public static void WaitForCompletion(this System.IObservable source) { } + public static void WaitForCompletion(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static void WaitForCompletion(this System.IObservable source, System.TimeSpan timeout) { } + public static void WaitForCompletion(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.TimeSpan timeout) { } + public static System.Exception? WaitForError(this System.IObservable source) { } + public static System.Exception? WaitForError(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.Exception? WaitForError(this System.IObservable source, System.TimeSpan timeout) { } + public static System.Exception? WaitForError(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.TimeSpan timeout) { } + public static T? WaitForValue(this System.IObservable source) { } + public static T? WaitForValue(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static T? WaitForValue(this System.IObservable source, System.TimeSpan timeout) { } + public static T? WaitForValue(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.TimeSpan timeout) { } + } + public static class Observables + { + public static System.IObservable Return(T value) { } + } + public static class ObserverExtensions + { + public static void FastForEach(this System.IObserver observer, System.Collections.Generic.IEnumerable source) { } + } + public static class ReactiveExtensions + { + public static System.IObservable AsSignal(this System.IObservable observable) { } + public static System.IObservable BufferUntil(this System.IObservable @this, char startsWith, char endsWith) { } + public static System.IObservable> BufferUntilIdle(this System.IObservable source, System.TimeSpan idleTime) { } + public static System.IObservable> BufferUntilIdle(this System.IObservable source, System.TimeSpan idleTime, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable> BufferUntilInactive(this System.IObservable source, System.TimeSpan inactivityPeriod) { } + public static System.IObservable> BufferUntilInactive(this System.IObservable source, System.TimeSpan inactivityPeriod, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable CatchAndReturn(this System.IObservable source, T fallback) { } + public static System.IObservable CatchAndReturn(this System.IObservable source, System.Func fallbackFactory) + where TException : System.Exception { } + public static System.IObservable CatchIgnore(this System.IObservable source) { } + public static System.IObservable CatchIgnore(this System.IObservable source, System.Action errorAction) + where TException : System.Exception { } + public static System.IObservable CatchReturn(this System.IObservable source, T fallback) { } + public static System.IObservable CatchReturnUnit(this System.IObservable source) { } + public static System.IObservable CombineLatestValuesAreAllFalse(this System.Collections.Generic.IEnumerable> sources) { } + public static System.IObservable CombineLatestValuesAreAllTrue(this System.Collections.Generic.IEnumerable> sources) { } + public static System.IObservable Conflate(this System.IObservable source, System.TimeSpan minimumUpdatePeriod, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable DebounceImmediate(this System.IObservable source, System.TimeSpan dueTime) { } + public static System.IObservable DebounceImmediate(this System.IObservable source, System.TimeSpan dueTime, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable DebounceUntil(this System.IObservable source, System.TimeSpan debounce, System.Func condition) { } + public static System.IObservable DebounceUntil(this System.IObservable source, System.TimeSpan debounce, System.Func condition, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable> DetectStale(this System.IObservable source, System.TimeSpan stalenessPeriod, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable DoOnDispose(this System.IObservable source, System.Action disposeAction) { } + public static System.IObservable DoOnSubscribe(this System.IObservable source, System.Action action) { } + public static System.IObservable DropIfBusy(this System.IObservable source, System.Func asyncAction) { } + public static System.IObservable Filter(this System.IObservable source, System.Text.RegularExpressions.Regex regex) { } + public static System.IObservable Filter(this System.IObservable source, string regexPattern) { } + public static System.IObservable FirstMatchFromCandidates(this System.Collections.Generic.IReadOnlyList candidates, System.Func> project, System.Func transform, System.Func predicate, TResult fallback) { } + public static System.IObservable ForEach(this System.IObservable> source) { } + public static System.IObservable ForEach(this System.IObservable> source, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable FromArray(this System.Collections.Generic.IEnumerable source) { } + public static System.IObservable FromArray(this System.Collections.Generic.IEnumerable source, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable GetMax(this System.IObservable @this, params System.IObservable[] sources) + where T : struct, System.IComparable { } + public static System.IObservable GetMin(this System.IObservable @this, params System.IObservable[] sources) + where T : struct, System.IComparable { } + public static System.IObservable> Heartbeat(this System.IObservable source, System.TimeSpan heartbeatPeriod, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable LatestOrDefault(this System.IObservable source, T defaultValue) { } + public static System.IObservable LogErrors(this System.IObservable source, System.Action logger) { } + public static System.IObservable Not(this System.IObservable source) { } + public static System.IObservable ObserveOnIf(this System.IObservable source, System.IObservable condition, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable ObserveOnIf(this System.IObservable source, bool condition, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable ObserveOnIf(this System.IObservable source, System.IObservable condition, ReactiveUI.Primitives.Concurrency.ISequencer trueScheduler, ReactiveUI.Primitives.Concurrency.ISequencer falseScheduler) { } + public static System.IObservable ObserveOnIf(this System.IObservable source, bool condition, ReactiveUI.Primitives.Concurrency.ISequencer trueScheduler, ReactiveUI.Primitives.Concurrency.ISequencer falseScheduler) { } + public static System.IObservable ObserveOnSafe(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable OnErrorRetry(this System.IObservable source) { } + public static System.IObservable OnErrorRetry(this System.IObservable source, System.Action onError) + where TException : System.Exception { } + public static System.IObservable OnErrorRetry(this System.IObservable source, System.Action onError, System.TimeSpan delay) + where TException : System.Exception { } + public static System.IObservable OnErrorRetry(this System.IObservable source, System.Action onError, int retryCount) + where TException : System.Exception { } + public static System.IObservable OnErrorRetry(this System.IObservable source, System.Action onError, int retryCount, System.TimeSpan delay) + where TException : System.Exception { } + public static System.IObservable OnErrorRetry(this System.IObservable source, System.Action onError, int retryCount, System.TimeSpan delay, ReactiveUI.Primitives.Concurrency.ISequencer delayScheduler) + where TException : System.Exception { } + public static void OnNext(this System.IObserver observer, params T[] events) { } + [return: System.Runtime.CompilerServices.TupleElementNames(new string[] { + "Previous", + "Current"})] + public static System.IObservable> Pairwise(this System.IObservable source) { } + [return: System.Runtime.CompilerServices.TupleElementNames(new string[] { + "True", + "False"})] + public static System.ValueTuple, System.IObservable> Partition(this System.IObservable source, System.Func predicate) { } + public static System.IObservable ReplayLastOnSubscribe(this System.IObservable source, T initialValue) { } + public static System.IObservable RetryForeverWithDelay(this System.IObservable source, System.TimeSpan delay) { } + public static System.IObservable RetryWithBackoff(this System.IObservable source, int maxRetries, System.TimeSpan initialDelay) { } + public static System.IObservable RetryWithBackoff(this System.IObservable source, int maxRetries, System.TimeSpan initialDelay, double backoffFactor, System.TimeSpan? maxDelay, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable RetryWithDelay(this System.IObservable source, int retryCount, System.Func delaySelector) { } + public static System.IObservable RetryWithFixedDelay(this System.IObservable source, int retryCount, System.TimeSpan delay) { } + public static System.IObservable RunAll(this System.Collections.Generic.IReadOnlyList> sources) { } + public static System.IObservable SampleLatest(this System.IObservable source, System.IObservable trigger) { } + public static System.IObservable ScanWithInitial(this System.IObservable source, TAccumulate initial, System.Func accumulator) { } + public static System.IObservable Schedule(this System.IObservable source, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Func function) { } + public static System.IObservable Schedule(this System.IObservable source, System.DateTimeOffset dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable Schedule(this System.IObservable source, System.TimeSpan dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable Schedule(this T value, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Func function) { } + public static System.IObservable Schedule(this T value, System.DateTimeOffset dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable Schedule(this T value, System.TimeSpan dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable Schedule(this System.IObservable source, System.DateTimeOffset dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Action action) { } + public static System.IObservable Schedule(this System.IObservable source, System.TimeSpan dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Action action) { } + public static System.IObservable Schedule(this System.IObservable source, System.TimeSpan dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Func function) { } + public static System.IObservable Schedule(this T value, System.DateTimeOffset dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Action action) { } + public static System.IObservable Schedule(this T value, System.TimeSpan dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Action action) { } + public static System.IObservable Schedule(this T value, System.TimeSpan dueTime, ReactiveUI.Primitives.Concurrency.ISequencer scheduler, System.Func function) { } + public static System.IDisposable ScheduleSafe(this ReactiveUI.Primitives.Concurrency.ISequencer? scheduler, System.Action action) { } + public static System.IDisposable ScheduleSafe(this ReactiveUI.Primitives.Concurrency.ISequencer? scheduler, System.TimeSpan dueTime, System.Action action) { } + public static System.IObservable SelectAsync(this System.IObservable source, System.Func> asyncSelector) { } + public static System.IObservable SelectAsync(this System.IObservable source, System.Func> asyncSelector) { } + public static System.IObservable SelectAsyncConcurrent(this System.IObservable source, System.Func> selector, int maxConcurrency) { } + public static System.IObservable SelectAsyncSequential(this System.IObservable source, System.Func> selector) { } + public static System.IObservable SelectConstant(this System.IObservable source, TResult constant) { } + public static System.IObservable SelectLatestAsync(this System.IObservable source, System.Func> selector) { } + public static System.IObservable SelectManyThen(this System.IObservable source, System.Func> first, System.Func> second) { } + public static System.IObservable Shuffle(this System.IObservable source) { } + public static System.IObservable SkipWhileNull(this System.IObservable source) + where T : class { } + public static System.IObservable Start(System.Action action, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable Start(System.Func function, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IDisposable SubscribeAsync(this System.IObservable source, System.Func onNext) { } + public static System.IDisposable SubscribeAsync(this System.IObservable source, System.Func onNext, System.Action onCompleted) { } + public static System.IDisposable SubscribeAsync(this System.IObservable source, System.Func onNext, System.Action onError) { } + public static System.IDisposable SubscribeAsync(this System.IObservable source, System.Func onNext, System.Action onError, System.Action onCompleted) { } + public static System.IDisposable SubscribeSynchronous(this System.IObservable source, System.Func onNext) { } + public static System.IDisposable SubscribeSynchronous(this System.IObservable source, System.Func onNext, System.Action onCompleted) { } + public static System.IDisposable SubscribeSynchronous(this System.IObservable source, System.Func onNext, System.Action onError) { } + public static System.IDisposable SubscribeSynchronous(this System.IObservable source, System.Func onNext, System.Action onError, System.Action onCompleted) { } + public static System.IObservable SwitchIfEmpty(this System.IObservable source, System.IObservable fallback) { } + public static System.IObservable SyncTimer(System.TimeSpan timeSpan) { } + public static System.IObservable SyncTimer(System.TimeSpan timeSpan, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + [return: System.Runtime.CompilerServices.TupleElementNames(new string[] { + "Value", + "Sync"})] + public static System.IObservable> SynchronizeAsync(this System.IObservable source) { } + [return: System.Runtime.CompilerServices.TupleElementNames(new string[] { + "Value", + "Sync"})] + public static System.IObservable> SynchronizeSynchronous(this System.IObservable source) { } + public static System.IObservable TakeUntil(this System.IObservable source, System.Func predicate) { } + public static System.IObservable ThrottleDistinct(this System.IObservable source, System.TimeSpan throttle) { } + public static System.IObservable ThrottleDistinct(this System.IObservable source, System.TimeSpan throttle, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable ThrottleFirst(this System.IObservable source, System.TimeSpan window) { } + public static System.IObservable ThrottleFirst(this System.IObservable source, System.TimeSpan window, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable ThrottleOnScheduler(this System.IObservable source, System.TimeSpan timeSpan, ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } + public static System.IObservable ThrottleUntilTrue(this System.IObservable source, System.TimeSpan throttle, System.Func predicate) { } + public static System.Threading.Tasks.Task ToHotTask(this System.IObservable source) { } + public static System.Threading.Tasks.ValueTask ToHotValueTask(this System.IObservable source) { } + public static System.IObservable ToPropertyObservable(this T source, System.Linq.Expressions.Expression> propertyExpression) + where T : System.ComponentModel.INotifyPropertyChanged { } + [return: System.Runtime.CompilerServices.TupleElementNames(new string[] { + "Observable", + "Observer"})] + public static System.ValueTuple, System.IObserver> ToReadOnlyBehavior(T initialValue) { } + public static System.IObservable TrySelect(this System.IObservable source, System.Func selector) { } + public static System.IObservable Using(this T obj, System.Action? action) + where T : System.IDisposable { } + public static System.IObservable Using(this T obj, System.Action? action, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) + where T : System.IDisposable { } + public static System.IObservable Using(this T obj, System.Func function) + where T : System.IDisposable { } + public static System.IObservable Using(this T obj, System.Func function, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) + where T : System.IDisposable { } + public static System.IObservable WaitUntil(this System.IObservable source, System.Func predicate) { } + public static System.IObservable WhereFalse(this System.IObservable source) { } + public static System.IObservable WhereIsNotNull(this System.IObservable observable) { } + public static System.IObservable WhereSelect(this System.IObservable source, System.Func predicate, System.Func selector) { } + public static System.IObservable WhereTrue(this System.IObservable source) { } + public static System.IObservable While(System.Func condition, System.Action action) { } + public static System.IObservable While(System.Func condition, System.Action action, ReactiveUI.Primitives.Concurrency.ISequencer? scheduler) { } + public static System.IObservable WithLimitedConcurrency(this System.Collections.Generic.IEnumerable> taskFunctions, int maxConcurrency) { } + } + public readonly struct Stale : ReactiveUI.Primitives.Extensions.IStale, System.IEquatable> + { + public Stale() { } + public Stale(T? update) { } + public bool IsStale { get; } + public T Update { get; } + } +} +namespace ReactiveUI.Primitives.Extensions.Operators +{ + public static class CachedObservables + { + public static System.IObservable UnitDefault { get; } + } +} \ No newline at end of file diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ApiApprovalTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ApiApprovalTests.cs new file mode 100644 index 0000000..ea89a56 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ApiApprovalTests.cs @@ -0,0 +1,23 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.ApiApproval; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// +/// Checks that the public API of ReactiveUI.Primitives.Extensions is consistent with previous releases, +/// highlighting any new or changed API. +/// +[ExcludeFromCodeCoverage] +public class ApiApprovalTests +{ + /// + /// Generates the public API for the ReactiveUI.Primitives.Extensions assembly. + /// + /// A task to monitor the process. + [Test] + public Task Extensions() => + typeof(ReactiveExtensions).Assembly.CheckApproval(); +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ContinuationTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ContinuationTests.cs new file mode 100644 index 0000000..0aaeddb --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ContinuationTests.cs @@ -0,0 +1,134 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// Tests for — the phase-barrier lock used to serialise emissions, +/// covering both the (Task) and +/// (ValueTask) entry points plus the already-locked short-circuit. +public class ContinuationTests +{ + /// Guard timeout to keep barrier rendezvous from hanging the test run. + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + /// Verifies pushes the item downstream, locks, + /// and completes once the phase is signalled by an unlock. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLockValueTaskNotLocked_ThenEmitsAndCompletesOnUnlock() + { + using var continuation = new Continuation(); + var values = new List(); + var observer = Observer.Create<(int Value, IDisposable Sync)>(v => values.Add(v.Value)); + + var lockTask = continuation.LockValueTask(1, observer); + var unlockTask = continuation.UnLock(); + + await lockTask.AsTask().WaitAsync(Timeout); + await unlockTask.WaitAsync(Timeout); + + await Assert.That(values.Count).IsEqualTo(1); + await Assert.That(values[0]).IsEqualTo(1); + await Assert.That(continuation.CompletedPhases).IsGreaterThanOrEqualTo(1); + } + + /// Verifies a second while already locked returns a + /// completed default value task and does not push the item downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLockValueTaskAlreadyLocked_ThenReturnsDefaultAndDropsItem() + { + using var continuation = new Continuation(); + var values = new List(); + var observer = Observer.Create<(int Value, IDisposable Sync)>(v => values.Add(v.Value)); + + var first = continuation.LockValueTask(1, observer); + var second = continuation.LockValueTask(2, observer); + + await Assert.That(second.IsCompleted).IsTrue(); + await second; + + var unlockTask = continuation.UnLock(); + await first.AsTask().WaitAsync(Timeout); + await unlockTask.WaitAsync(Timeout); + + await Assert.That(values.Count).IsEqualTo(1); + await Assert.That(values[0]).IsEqualTo(1); + } + + /// Verifies (the Task overload) emits and completes on unlock. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLockNotLocked_ThenEmitsAndCompletesOnUnlock() + { + using var continuation = new Continuation(); + var values = new List(); + var observer = Observer.Create<(int Value, IDisposable Sync)>(v => values.Add(v.Value)); + + var lockTask = continuation.Lock(1, observer); + var unlockTask = continuation.UnLock(); + + await lockTask.WaitAsync(Timeout); + await unlockTask.WaitAsync(Timeout); + + await Assert.That(values.Count).IsEqualTo(1); + } + + /// Verifies a second while already locked returns a completed + /// task and drops the item. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLockAlreadyLocked_ThenReturnsCompletedAndDropsItem() + { + using var continuation = new Continuation(); + var values = new List(); + var observer = Observer.Create<(int Value, IDisposable Sync)>(v => values.Add(v.Value)); + + var first = continuation.Lock(1, observer); + var second = continuation.Lock(2, observer); + + await Assert.That(second.IsCompleted).IsTrue(); + + var unlockTask = continuation.UnLock(); + await first.WaitAsync(Timeout); + await unlockTask.WaitAsync(Timeout); + + await Assert.That(values.Count).IsEqualTo(1); + } + + /// Verifies that unlocking a continuation that was never locked completes immediately. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenUnlockNotLocked_ThenCompletesImmediately() + { + using var continuation = new Continuation(); + + await continuation.UnLock().WaitAsync(Timeout); + + await Assert.That(continuation.CompletedPhases).IsEqualTo(0); + } + + /// Verifies disposing twice is an idempotent no-op on the second call. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDisposeTwice_ThenSecondDisposeIsNoOp() + { + var continuation = new Continuation(); + Exception? caught = null; + + try + { + continuation.Dispose(); + continuation.Dispose(); + } + catch (Exception ex) + { + caught = ex; + } + + await Assert.That(caught).IsNull(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/CurrentValueSubjectTests.MultiObserver.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/CurrentValueSubjectTests.MultiObserver.cs new file mode 100644 index 0000000..2b2f935 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/CurrentValueSubjectTests.MultiObserver.cs @@ -0,0 +1,285 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// Multi-observer and post-terminal coverage for +/// — copy-on-write growth, mid-array unsubscribe, collapse back to single-observer, late +/// subscribers after error or completion, and dispose with active observers. +public partial class CurrentValueSubjectTests +{ + /// Initial value for multi-observer tests. + private const int MultiInitialValue = 1; + + /// Verifies that three concurrent observers all receive the latest value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThreeObserversAndOnNext_ThenAllReceiveValue() + { + const int Update = 2; + using var subject = new CurrentValueSubject(MultiInitialValue); + var a = new List(); + var b = new List(); + var c = new List(); + + using var subA = subject.Subscribe(a.Add); + using var subB = subject.Subscribe(b.Add); + using var subC = subject.Subscribe(c.Add); + + subject.OnNext(Update); + + await Assert.That(a).IsCollectionEqualTo([MultiInitialValue, Update]); + await Assert.That(b).IsCollectionEqualTo([MultiInitialValue, Update]); + await Assert.That(c).IsCollectionEqualTo([MultiInitialValue, Update]); + } + + /// Verifies that disposing the middle observer of a 3-observer subject does not + /// affect the other observers' delivery. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMiddleObserverDisposed_ThenOthersStillReceive() + { + const int Update = 2; + using var subject = new CurrentValueSubject(MultiInitialValue); + var a = new List(); + var b = new List(); + var c = new List(); + + using var subA = subject.Subscribe(a.Add); + var subB = subject.Subscribe(b.Add); + using var subC = subject.Subscribe(c.Add); + + subB.Dispose(); + subject.OnNext(Update); + + await Assert.That(a).IsCollectionEqualTo([MultiInitialValue, Update]); + await Assert.That(b).IsCollectionEqualTo([MultiInitialValue]); + await Assert.That(c).IsCollectionEqualTo([MultiInitialValue, Update]); + } + + /// Verifies that going from two observers back to one collapses to the + /// single-observer fast path while still broadcasting correctly. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSecondObserverDisposedFromPair_ThenSingleObserverStillReceives() + { + const int Update = 2; + using var subject = new CurrentValueSubject(MultiInitialValue); + var a = new List(); + var b = new List(); + + using var subA = subject.Subscribe(a.Add); + var subB = subject.Subscribe(b.Add); + subB.Dispose(); + + subject.OnNext(Update); + + await Assert.That(a).IsCollectionEqualTo([MultiInitialValue, Update]); + await Assert.That(b).IsCollectionEqualTo([MultiInitialValue]); + } + + /// Disposing the first observer of a 2-observer subject exercises Unsubscribe's + /// index == 0 ? existing[1] : existing[0] ternary on the true branch — the surviving + /// observer collapses back to the single-observer fast path. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFirstObserverOfPairDisposed_ThenSingleSurvivorReceives() + { + const int Update = 2; + using var subject = new CurrentValueSubject(MultiInitialValue); + var a = new List(); + var b = new List(); + + var subA = subject.Subscribe(a.Add); + using var subB = subject.Subscribe(b.Add); + + // Dispose subA from the two-observer array; Unsubscribe's `index == 0 ? existing[1] : existing[0]` + // ternary picks the true branch, collapsing _observer to subB. + subA.Dispose(); + subject.OnNext(Update); + + await Assert.That(a).IsCollectionEqualTo([MultiInitialValue]); + await Assert.That(b).IsCollectionEqualTo([MultiInitialValue, Update]); + } + + /// Verifies that disposing the first observer of a 3-observer subject works + /// (collapse exercises the index==0 branch of the shrink path). + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFirstObserverDisposed_ThenOthersStillReceive() + { + const int Update = 2; + using var subject = new CurrentValueSubject(MultiInitialValue); + var a = new List(); + var b = new List(); + var c = new List(); + + var subA = subject.Subscribe(a.Add); + using var subB = subject.Subscribe(b.Add); + using var subC = subject.Subscribe(c.Add); + + subA.Dispose(); + subject.OnNext(Update); + + await Assert.That(a).IsCollectionEqualTo([MultiInitialValue]); + await Assert.That(b).IsCollectionEqualTo([MultiInitialValue, Update]); + await Assert.That(c).IsCollectionEqualTo([MultiInitialValue, Update]); + } + + /// Verifies that disposing the last observer of a 3-observer subject works + /// (collapse exercises the tail-only branch of the shrink path). + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLastObserverDisposed_ThenOthersStillReceive() + { + const int Update = 2; + using var subject = new CurrentValueSubject(MultiInitialValue); + var a = new List(); + var b = new List(); + var c = new List(); + + using var subA = subject.Subscribe(a.Add); + using var subB = subject.Subscribe(b.Add); + var subC = subject.Subscribe(c.Add); + + subC.Dispose(); + subject.OnNext(Update); + + await Assert.That(a).IsCollectionEqualTo([MultiInitialValue, Update]); + await Assert.That(b).IsCollectionEqualTo([MultiInitialValue, Update]); + await Assert.That(c).IsCollectionEqualTo([MultiInitialValue]); + } + + /// Verifies that subscribing after the subject has errored immediately delivers the error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscribeAfterError_ThenErrorDeliveredImmediately() + { + using var subject = new CurrentValueSubject(MultiInitialValue); + var expected = new InvalidOperationException("late-error"); + subject.OnError(expected); + + Exception? caught = null; + using var sub = subject.Subscribe(static _ => { }, ex => caught = ex); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that subscribing after the subject has completed delivers the cached + /// value and an immediate completion. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscribeAfterCompleted_ThenReplayThenCompletes() + { + using var subject = new CurrentValueSubject(MultiInitialValue); + subject.OnCompleted(); + + var values = new List(); + var completed = false; + using var sub = subject.Subscribe(values.Add, () => completed = true); + + await Assert.That(values).IsCollectionEqualTo([MultiInitialValue]); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that OnError broadcasts to multiple observers and is idempotent. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMultipleObserversAndOnError_ThenAllReceiveError() + { + using var subject = new CurrentValueSubject(MultiInitialValue); + Exception? errA = null; + Exception? errB = null; + Exception? errC = null; + var expected = new InvalidOperationException("multi-error"); + + using var subA = subject.Subscribe(static _ => { }, ex => errA = ex); + using var subB = subject.Subscribe(static _ => { }, ex => errB = ex); + using var subC = subject.Subscribe(static _ => { }, ex => errC = ex); + + subject.OnError(expected); + + // Second OnError is a no-op. + subject.OnError(new InvalidOperationException("ignored")); + + await Assert.That(errA).IsSameReferenceAs(expected); + 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]); + } + + /// Verifies the multi-observer Unsubscribe path tolerates a stale dispose — + /// after a middle observer is detached from a 4-observer array, disposing its returned + /// subscription a second time hits the Array.IndexOf not-found early-return. + /// The 4-observer setup keeps _observers non-null after the first dispose (the + /// 2-observer setup collapses back to the single-observer fast path, which hits a + /// different short-circuit instead of the IndexOf path). + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMultiObserverDisposedTwice_ThenSecondDisposeIsNoOp() + { + const int Update = 2; + using var subject = new CurrentValueSubject(MultiInitialValue); + var a = new List(); + var b = new List(); + var c = new List(); + var d = new List(); + + using var subA = subject.Subscribe(a.Add); + var subB = subject.Subscribe(b.Add); + using var subC = subject.Subscribe(c.Add); + using var subD = subject.Subscribe(d.Add); + + subB.Dispose(); + subB.Dispose(); + + subject.OnNext(Update); + + await Assert.That(a).IsCollectionEqualTo([MultiInitialValue, Update]); + await Assert.That(b).IsCollectionEqualTo([MultiInitialValue]); + await Assert.That(c).IsCollectionEqualTo([MultiInitialValue, Update]); + await Assert.That(d).IsCollectionEqualTo([MultiInitialValue, Update]); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/CurrentValueSubjectTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/CurrentValueSubjectTests.cs new file mode 100644 index 0000000..6b043c0 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/CurrentValueSubjectTests.cs @@ -0,0 +1,220 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; +using ReactiveUI.Primitives.Extensions.Operators; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// Tests for , , +/// and the singletons. +public partial class CurrentValueSubjectTests +{ + /// Initial value used by subject tests so the replay value is unambiguous. + private const int InitialValue = 42; + + /// Second value that overwrites the initial value via OnNext. + private const int SecondValue = 7; + + /// Third value used to assert later observers see only the most recent emission. + private const int ThirdValue = 99; + + /// Verifies the subject replays its initial value to a new subscriber. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenNewSubscriber_ThenReceivesInitialValueImmediately() + { + using var subject = new CurrentValueSubject(InitialValue); + var results = new List(); + + using var sub = subject.Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo([InitialValue]); + await Assert.That(subject.Value).IsEqualTo(InitialValue); + } + + /// Verifies that OnNext updates the cached value and broadcasts to a single observer. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnNextSingleObserver_ThenValueAndBroadcastUpdate() + { + using var subject = new CurrentValueSubject(InitialValue); + var results = new List(); + + using var sub = subject.Subscribe(results.Add); + subject.OnNext(SecondValue); + + await Assert.That(results).IsCollectionEqualTo([InitialValue, SecondValue]); + await Assert.That(subject.Value).IsEqualTo(SecondValue); + } + + /// Verifies that two observers both receive the initial value and subsequent emissions. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTwoObservers_ThenBothReceiveReplayAndOnNext() + { + using var subject = new CurrentValueSubject(InitialValue); + var first = new List(); + var second = new List(); + + using var sub1 = subject.Subscribe(first.Add); + using var sub2 = subject.Subscribe(second.Add); + subject.OnNext(SecondValue); + + await Assert.That(first).IsCollectionEqualTo([InitialValue, SecondValue]); + await Assert.That(second).IsCollectionEqualTo([InitialValue, SecondValue]); + } + + /// Verifies that a late subscriber after multiple OnNext receives only the most recent value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLateSubscriber_ThenReceivesOnlyLatest() + { + using var subject = new CurrentValueSubject(InitialValue); + subject.OnNext(SecondValue); + subject.OnNext(ThirdValue); + + var results = new List(); + using var sub = subject.Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo([ThirdValue]); + } + + /// Verifies that disposing the subscription stops future deliveries to the disposed observer. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscriptionDisposed_ThenNoFurtherDeliveries() + { + using var subject = new CurrentValueSubject(InitialValue); + var results = new List(); + var sub = subject.Subscribe(results.Add); + + sub.Dispose(); + subject.OnNext(SecondValue); + + await Assert.That(results).IsCollectionEqualTo([InitialValue]); + } + + /// Verifies that OnCompleted terminates active subscribers and that late subscribers also see completion. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnCompleted_ThenObserversReceiveCompletion() + { + using var subject = new CurrentValueSubject(InitialValue); + var completedFirst = false; + var completedLate = false; + var lateValues = new List(); + + using var subFirst = subject.Subscribe(static _ => { }, () => completedFirst = true); + subject.OnCompleted(); + + using var subLate = subject.Subscribe(lateValues.Add, () => completedLate = true); + + await Assert.That(completedFirst).IsTrue(); + await Assert.That(completedLate).IsTrue(); + + // Late subscriber still sees the replayed value before completion. + await Assert.That(lateValues).IsCollectionEqualTo([InitialValue]); + } + + /// Verifies that OnError terminates active subscribers and that late subscribers replay the error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnError_ThenObserversReceiveError() + { + var expected = new InvalidOperationException("boom"); + using var subject = new CurrentValueSubject(InitialValue); + Exception? firstError = null; + Exception? lateError = null; + + using var subFirst = subject.Subscribe(static _ => { }, ex => firstError = ex); + subject.OnError(expected); + + using var subLate = subject.Subscribe(static _ => { }, ex => lateError = ex); + + await Assert.That(firstError).IsEqualTo(expected); + await Assert.That(lateError).IsEqualTo(expected); + } + + /// Verifies that OnNext after disposal is silently ignored. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDisposedThenOnNext_ThenIgnored() + { + var subject = new CurrentValueSubject(InitialValue); + var results = new List(); + using var sub = subject.Subscribe(results.Add); + + subject.Dispose(); + subject.OnNext(SecondValue); + + await Assert.That(results).IsCollectionEqualTo([InitialValue]); + } + + /// Verifies that subscribing after disposal immediately errors with . + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscribeAfterDispose_ThenObjectDisposedExceptionDelivered() + { + var subject = new CurrentValueSubject(InitialValue); + subject.Dispose(); + + Exception? error = null; + using var sub = subject.Subscribe(static _ => { }, ex => error = ex); + + await Assert.That(error).IsTypeOf(); + } + + /// Verifies that AsObservable returns a hide-the-observer view that still delivers values. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAsObservable_ThenDeliversValuesButHidesObserverApi() + { + using var subject = new CurrentValueSubject(InitialValue); + var view = subject.AsObservable(); + var results = new List(); + + using var sub = view.Subscribe(results.Add); + subject.OnNext(SecondValue); + + await Assert.That(results).IsCollectionEqualTo([InitialValue, SecondValue]); + await Assert.That(view).IsNotTypeOf>(); + } + + /// Verifies that emits exactly one value and completes. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSingleValueObservableSubscribed_ThenEmitsOnceAndCompletes() + { + var observable = new SingleValueObservable(SecondValue); + var results = new List(); + var completed = false; + + using var sub = observable.Subscribe(results.Add, () => completed = true); + + await Assert.That(results).IsCollectionEqualTo([SecondValue]); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that emits a single and completes. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCachedUnitDefaultSubscribed_ThenEmitsUnitAndCompletes() + { + var count = 0; + var completed = false; + + var localCount = 0; + using var sub = CachedObservables.UnitDefault.Subscribe( + _ => localCount++, + () => completed = true); + count = localCount; + + await Assert.That(Volatile.Read(ref count)).IsEqualTo(1); + await Assert.That(completed).IsTrue(); + + // The same singleton instance should service repeated calls without re-allocating. + await Assert.That(CachedObservables.UnitDefault).IsSameReferenceAs(CachedObservables.UnitDefault); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/DisposableExtensionsTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/DisposableExtensionsTests.cs new file mode 100644 index 0000000..a1a3262 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/DisposableExtensionsTests.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Disposables; +using System.Reactive.Disposables.Fluent; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// +/// Tests disposable extensions. +/// +public class DisposableExtensionsTests +{ + /// + /// Tests DisposeWith returns a disposable. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task GivenNull_WhenDisposeWith_ThenExceptionThrown() + { + // Given + var sut = Disposable.Create(() => { }); + + // When + var result = Assert.Throws(() => sut.DisposeWith((CompositeDisposable)null!)); + + // Then + await Assert.That(result).IsTypeOf(); + } + + /// + /// Tests DisposeWith disposes the underlying disposable. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task GivenDisposable_WhenDisposeWith_ThenDisposed() + { + // Given + var sut = new CompositeDisposable(); + var compositeDisposable = new CompositeDisposable(); + sut.DisposeWith(compositeDisposable); + + // When + compositeDisposable.Dispose(); + + // Then + await Assert.That(sut.IsDisposed).IsTrue(); + } + + /// + /// Tests DisposeWith returns the original disposable. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task GivenDisposable_WhenDisposeWith_ThenReturnsDisposable() + { + // Given, When + var sut = new CompositeDisposable(); + var compositeDisposable = new CompositeDisposable(); + var result = sut.DisposeWith(compositeDisposable); + + // Then + await Assert.That(result).IsCollectionEqualTo(sut); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/GlobalTestSetup.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/GlobalTestSetup.cs new file mode 100644 index 0000000..239e7c7 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/GlobalTestSetup.cs @@ -0,0 +1,25 @@ +// 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.Primitives.Extensions.Tests; + +/// +/// Configures assembly-wide TUnit defaults via a hook so they +/// are in place before any test executes. +/// +internal static class GlobalTestSetup +{ + /// + /// Caps every test at 60 seconds. Without a default cap, a single flaky test that hangs + /// stalls the entire assembly (the whole suite is serialised via + /// [assembly: NotInParallel(nameof(UnhandledExceptionHandler))]) and we lose the + /// per-test failure signal — the CI just reports the workflow-level timeout. 60s is far + /// above every legitimate test (slowest non-cancellation test is ~5s) so any future hang + /// fails its own test with a clear message instead of killing the whole run. + /// + /// The TUnit test-discovery context exposing programmatic settings. + [Before(HookType.TestDiscovery)] + public static void ConfigureDefaults(BeforeTestDiscoveryContext context) => + context.Settings.Timeouts.DefaultTestTimeout = TimeSpan.FromSeconds(60); +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ActionDisposableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ActionDisposableTests.cs new file mode 100644 index 0000000..b2e8503 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ActionDisposableTests.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. + +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.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++); + + await Assert.That(disposable.IsDisposed).IsFalse(); + + disposable.Dispose(); + disposable.Dispose(); + disposable.Dispose(); + + await Assert.That(disposable.IsDisposed).IsTrue(); + await Assert.That(invocations).IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ConcurrencyRaceHelpersTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ConcurrencyRaceHelpersTests.cs new file mode 100644 index 0000000..2fa9cde --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.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.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Tests.Internal; + +/// Direct RxVoid 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.Primitives.Extensions.Tests/Internal/DisposableBagTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/DisposableBagTests.cs new file mode 100644 index 0000000..1a90eb4 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/DisposableBagTests.cs @@ -0,0 +1,121 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Extensions.Tests.Internal; + +/// Coverage for — inline-slot fill, overflow growth, +/// add-after-dispose immediate disposal, three-arg constructor, and dispose-order guarantees. +public class DisposableBagTests +{ + /// Verifies that the parameterless constructor accepts inline slot fills and disposes both. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDefaultBagFilledViaAdd_ThenDisposesBoth() + { + var bag = new DisposableBag(); + var d1 = new CountedDisposable(); + var d2 = new CountedDisposable(); + + bag.Add(d1); + bag.Add(d2); + await Assert.That(bag.IsDisposed).IsFalse(); + + bag.Dispose(); + + await Assert.That(bag.IsDisposed).IsTrue(); + await Assert.That(d1.DisposeCount).IsEqualTo(1); + await Assert.That(d2.DisposeCount).IsEqualTo(1); + } + + /// Verifies that adding a null disposable is a no-op. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAddNull_ThenIgnored() + { + var bag = new DisposableBag(); + bag.Add(null!); + bag.Dispose(); + + await Assert.That(bag).IsNotNull(); + } + + /// Verifies that overflow growth and disposal works for more than three entries. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOverflowGrowthRequired_ThenAllEntriesDisposed() + { + const int Count = 8; + var bag = new DisposableBag(); + var entries = new CountedDisposable[Count]; + for (var i = 0; i < Count; i++) + { + entries[i] = new CountedDisposable(); + bag.Add(entries[i]); + } + + bag.Dispose(); + + for (var i = 0; i < Count; i++) + { + await Assert.That(entries[i].DisposeCount).IsEqualTo(1); + } + } + + /// Verifies that the three-arg constructor populates inline slots plus overflow and disposes all. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThreeArgConstructor_ThenAllThreeDisposed() + { + var d1 = new CountedDisposable(); + var d2 = new CountedDisposable(); + var d3 = new CountedDisposable(); + var bag = new DisposableBag(d1, d2, d3); + + bag.Dispose(); + + await Assert.That(d1.DisposeCount).IsEqualTo(1); + await Assert.That(d2.DisposeCount).IsEqualTo(1); + await Assert.That(d3.DisposeCount).IsEqualTo(1); + } + + /// Verifies that double-dispose is idempotent. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDisposeCalledTwice_ThenIdempotent() + { + var d1 = new CountedDisposable(); + var bag = new DisposableBag(d1, new CountedDisposable()); + + bag.Dispose(); + bag.Dispose(); + + await Assert.That(d1.DisposeCount).IsEqualTo(1); + } + + /// Verifies that adding after disposal disposes the supplied entry immediately. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAddAfterDispose_ThenSuppliedDisposableDisposedImmediately() + { + var bag = new DisposableBag(); + bag.Dispose(); + + var late = new CountedDisposable(); + bag.Add(late); + + await Assert.That(late.DisposeCount).IsEqualTo(1); + } + + /// Tracking disposable used to count dispose invocations. + private sealed class CountedDisposable : 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.Primitives.Extensions.Tests/Internal/DisposableSlotHelperTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/DisposableSlotHelperTests.cs new file mode 100644 index 0000000..e1e9044 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.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.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Extensions.Tests.Internal; + +/// Direct RxVoid 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.Primitives.Extensions.Tests/Internal/FirstAsTaskHelperTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/FirstAsTaskHelperTests.cs new file mode 100644 index 0000000..318dbda --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/FirstAsTaskHelperTests.cs @@ -0,0 +1,165 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Linq; +using System.Reactive.Subjects; + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Tests.Internal; + +/// Tests for covering the error and empty-completion +/// paths that ToHotTask does not otherwise exercise. +public class FirstAsTaskHelperTests +{ + /// Value used by the latch-on-first-emission test. + private const int FirstValue = 7; + + /// Value used by the latch-on-first-emission test to verify subsequent values are ignored. + private const int SecondValue = 11; + + /// Verifies the helper faults the task when the source emits an error before any value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceErrors_ThenTaskFaults() + { + var expected = new InvalidOperationException("boom"); + var task = FirstAsTaskHelper.FirstAsTask(Observable.Throw(expected)); + + var ex = await Assert.ThrowsAsync(async () => await task); + await Assert.That(ex).IsSameReferenceAs(expected); + } + + /// Verifies the helper faults the task when the source completes empty. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceCompletesEmpty_ThenTaskFaultsWithInvalidOperation() + { + var task = FirstAsTaskHelper.FirstAsTask(Observable.Empty()); + + await Assert.ThrowsAsync(async () => await task); + } + + /// Verifies the task latches on the first emission and ignores subsequent values. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceEmitsMultiple_ThenTaskCompletesWithFirst() + { + var subject = new Subject(); + var task = FirstAsTaskHelper.FirstAsTask(subject); + + subject.OnNext(FirstValue); + subject.OnNext(SecondValue); + subject.OnCompleted(); + + 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!)); + + /// Exercises the Subscription?.Dispose() null-conditional branch on + /// FirstObserver.OnNext — a source that synchronously emits during Subscribe + /// (such as ) fires OnNext before + /// FirstAsTask can assign the Subscription property, so the latch-and-cleanup + /// path sees Subscription == null and the conditional dispose becomes a no-op. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSyncSourceEmits_ThenSubscriptionNullBranchSkipsDispose() + { + const int Sentinel = 17; + + var task = FirstAsTaskHelper.FirstAsTask(Observable.Return(Sentinel)); + + await Assert.That(await task).IsEqualTo(Sentinel); + } + + /// 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.Primitives.Extensions.Tests/Internal/FirstAsValueTaskHelperTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/FirstAsValueTaskHelperTests.cs new file mode 100644 index 0000000..e8cc0b4 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/FirstAsValueTaskHelperTests.cs @@ -0,0 +1,175 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Linq; +using System.Reactive.Subjects; + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Tests.Internal; + +/// Tests for covering the value, error, and +/// empty-completion paths the pooled ToHotValueTask source exposes, plus the pool reuse and +/// post-settle drop branches. +public class FirstAsValueTaskHelperTests +{ + /// Value used by the latch-on-first-emission tests. + private const int FirstValue = 7; + + /// Value used to verify subsequent values are ignored. + private const int SecondValue = 11; + + /// Verifies the helper completes with the first value the source emits. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceEmits_ThenValueTaskCompletesWithFirst() + { + var subject = new Subject(); + var task = FirstAsValueTaskHelper.FirstAsValueTask(subject); + + subject.OnNext(FirstValue); + subject.OnNext(SecondValue); + subject.OnCompleted(); + + await Assert.That(await task).IsEqualTo(FirstValue); + } + + /// Verifies the helper faults the value task when the source errors before any value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceErrors_ThenValueTaskFaults() + { + var expected = new InvalidOperationException("boom"); + var task = FirstAsValueTaskHelper.FirstAsValueTask(Observable.Throw(expected)); + + var ex = await Assert.ThrowsAsync(async () => await task); + await Assert.That(ex).IsSameReferenceAs(expected); + } + + /// Verifies the helper faults the value task when the source completes empty. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceCompletesEmpty_ThenValueTaskFaultsWithInvalidOperation() + { + var task = FirstAsValueTaskHelper.FirstAsValueTask(Observable.Empty()); + + await Assert.ThrowsAsync(async () => await task); + } + + /// Verifies the helper throws when the source argument is null. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceNull_ThenThrowsArgumentNullException() => + await Assert.ThrowsAsync( + static async () => await FirstAsValueTaskHelper.FirstAsValueTask(null!)); + + /// Exercises the Subscription?.Dispose() null-conditional branch when a source + /// synchronously emits during Subscribe before the subscription field is assigned. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSyncSourceEmits_ThenSubscriptionNullBranchSkipsDispose() + { + const int Sentinel = 17; + + var value = await FirstAsValueTaskHelper.FirstAsValueTask(Observable.Return(Sentinel)); + + await Assert.That(value).IsEqualTo(Sentinel); + } + + /// Verifies the pooled source is reused across sequential calls — a second call after the + /// first has settled returns to the pool and resolves correctly. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCalledSequentially_ThenPooledSourceReused() + { + var first = await FirstAsValueTaskHelper.FirstAsValueTask(Observable.Return(FirstValue)); + var second = await FirstAsValueTaskHelper.FirstAsValueTask(Observable.Return(SecondValue)); + + await Assert.That(first).IsEqualTo(FirstValue); + await Assert.That(second).IsEqualTo(SecondValue); + } + + /// Verifies awaiting the value task before the source emits registers a continuation on the + /// pooled source (the incomplete-await path) and resolves once the value later arrives. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAwaitedBeforeEmission_ThenCompletesOnLaterValue() + { + var subject = new Subject(); + var pending = FirstAsValueTaskHelper.FirstAsValueTask(subject).AsTask(); + + subject.OnNext(FirstValue); + + var result = await pending.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(result).IsEqualTo(FirstValue); + } + + /// Verifies emissions arriving after the value task has already settled are silently ignored. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSecondTerminalAfterSettled_ThenIgnored() + { + var source = new InvasiveObservable(); + var task = FirstAsValueTaskHelper.FirstAsValueTask(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 a second OnError arriving after the first is dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSecondOnErrorAfterSettled_ThenIgnored() + { + var source = new InvasiveObservable(); + var task = FirstAsValueTaskHelper.FirstAsValueTask(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 a second OnCompleted arriving after the first is dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSecondOnCompletedAfterSettled_ThenIgnored() + { + var source = new InvasiveObservable(); + var task = FirstAsValueTaskHelper.FirstAsValueTask(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 the pooled first-value 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.Primitives.Extensions.Tests/Internal/IndexedSubscribeHelperTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/IndexedSubscribeHelperTests.cs new file mode 100644 index 0000000..d3904b5 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/IndexedSubscribeHelperTests.cs @@ -0,0 +1,138 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Tests.Internal; + +/// Tests for , the shared indexed-subscribe loop used +/// by the synchronous combine-then-reduce operator family. +public class IndexedSubscribeHelperTests +{ + /// Value emitted by source 0 in the index-threading test. + private const int Source0Value = 20; + + /// Value emitted by source 1 in the index-threading test. + private const int Source1Value = 10; + + /// Value emitted by source 2 in the index-threading test. + private const int Source2Value = 30; + + /// First post-subscribe value used by the dispose-guard test. + private const int DisposeValue1 = 1; + + /// Post-dispose value used by the dispose-guard test. + private const int DisposeValue2 = 2; + + /// Post-dispose value emitted by the second source in the dispose-guard test. + private const int DisposeValue3 = 3; + + /// Verifies that the helper threads each source's positional index through to the OnNext hook. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourcesEmit_ThenOnNextReceivesPerSourceIndex() + { + using var s0 = new Subject(); + using var s1 = new Subject(); + using var s2 = new Subject(); + var captured = new List<(int Index, int Value)>(); + + using var sub = IndexedSubscribeHelper.SubscribeIndexed( + [s0, s1, s2], + (i, v) => captured.Add((i, v)), + static _ => { }, + static _ => { }); + + s1.OnNext(Source1Value); + s0.OnNext(Source0Value); + s2.OnNext(Source2Value); + + const int FirstIndex = 0; + const int SecondIndex = 1; + const int ThirdIndex = 2; + await Assert.That(captured).IsCollectionEqualTo( + [(SecondIndex, Source1Value), (FirstIndex, Source0Value), (ThirdIndex, Source2Value)]); + } + + /// Verifies that the helper forwards any source's error through the shared OnError hook. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAnySourceErrors_ThenOnErrorForwarded() + { + using var s0 = new Subject(); + using var s1 = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException("source error"); + + using var sub = IndexedSubscribeHelper.SubscribeIndexed( + [s0, s1], + static (_, _) => { }, + ex => caught = ex, + static _ => { }); + + s1.OnError(expected); + + await Assert.That(caught).IsEqualTo(expected); + } + + /// Verifies that the helper threads each source's positional index through to the OnCompleted hook. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourcesComplete_ThenOnCompletedReceivesPerSourceIndex() + { + using var s0 = new Subject(); + using var s1 = new Subject(); + var completed = new List(); + + using var sub = IndexedSubscribeHelper.SubscribeIndexed( + [s0, s1], + static (_, _) => { }, + static _ => { }, + completed.Add); + + const int FirstIndex = 0; + const int SecondIndex = 1; + s1.OnCompleted(); + s0.OnCompleted(); + + await Assert.That(completed).IsCollectionEqualTo([SecondIndex, FirstIndex]); + } + + /// Verifies that disposing the returned aggregate stops further deliveries from every source. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDisposed_ThenNoFurtherDeliveries() + { + using var s0 = new Subject(); + using var s1 = new Subject(); + var captured = new List<(int Index, int Value)>(); + + var sub = IndexedSubscribeHelper.SubscribeIndexed( + [s0, s1], + (i, v) => captured.Add((i, v)), + static _ => { }, + static _ => { }); + + s0.OnNext(DisposeValue1); + sub.Dispose(); + s0.OnNext(DisposeValue2); + s1.OnNext(DisposeValue3); + + await Assert.That(captured).IsCollectionEqualTo([(0, DisposeValue1)]); + } + + /// Verifies that the helper validates its null arguments and surfaces a meaningful exception. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenNullArgs_ThenArgumentNullException() + { + const IReadOnlyList> NullSources = null!; + var ex = Assert.Throws(() => + IndexedSubscribeHelper.SubscribeIndexed(NullSources, static (_, _) => { }, static _ => { }, static _ => { })); + + await Assert.That(ex).IsNotNull(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/InternalDisposablesTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/InternalDisposablesTests.cs new file mode 100644 index 0000000..5f5b90a --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/InternalDisposablesTests.cs @@ -0,0 +1,173 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Extensions.Tests.Internal; + +/// Tests for the internal disposable holders +/// (, , ) +/// that back the sync-side sinks. +public class InternalDisposablesTests +{ + /// Verifies that assigning a new inner disposes the previous one. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSwapDisposableReplaced_ThenPreviousDisposed() + { + var holder = new SwapDisposable(); + var first = new CountingDisposable(); + var second = new CountingDisposable(); + + holder.Disposable = first; + await Assert.That(holder.Disposable).IsSameReferenceAs(first); + + holder.Disposable = second; + await Assert.That(first.DisposeCount).IsEqualTo(1); + await Assert.That(holder.Disposable).IsSameReferenceAs(second); + + holder.Dispose(); + await Assert.That(second.DisposeCount).IsEqualTo(1); + } + + /// Verifies that once disposed, subsequent assignments dispose the supplied value immediately. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSwapDisposableAfterDispose_ThenAssignmentDisposesValue() + { + var holder = new SwapDisposable(); + holder.Dispose(); + + var late = new CountingDisposable(); + holder.Disposable = late; + + await Assert.That(late.DisposeCount).IsEqualTo(1); + } + + /// Verifies that double-dispose is a no-op on . + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSwapDisposableDisposedTwice_ThenNoOp() + { + var inner = new CountingDisposable(); + var holder = new SwapDisposable { Disposable = inner }; + + holder.Dispose(); + holder.Dispose(); + + await Assert.That(inner.DisposeCount).IsEqualTo(1); + } + + /// Verifies that replacement does NOT dispose the previous inner. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMutableDisposableReplaced_ThenPreviousNotDisposed() + { + var holder = new MutableDisposable(); + var first = new CountingDisposable(); + var second = new CountingDisposable(); + + holder.Disposable = first; + holder.Disposable = second; + + await Assert.That(first.DisposeCount).IsEqualTo(0); + + holder.Dispose(); + await Assert.That(second.DisposeCount).IsEqualTo(1); + } + + /// Verifies that assigning after dispose immediately disposes the value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMutableDisposableAfterDispose_ThenAssignmentDisposesValue() + { + var holder = new MutableDisposable(); + holder.Dispose(); + + var late = new CountingDisposable(); + holder.Disposable = late; + + await Assert.That(late.DisposeCount).IsEqualTo(1); + } + + /// Verifies that double-dispose is a no-op. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMutableDisposableDisposedTwice_ThenNoOp() + { + var inner = new CountingDisposable(); + var holder = new MutableDisposable { Disposable = inner }; + + holder.Dispose(); + holder.Dispose(); + + await Assert.That(inner.DisposeCount).IsEqualTo(1); + } + + /// Verifies single-assignment succeeds and reflects state. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnceDisposableAssignedOnce_ThenIsAssignedTrueAndDisposed() + { + var holder = new OnceDisposable(); + var inner = new CountingDisposable(); + + await Assert.That(holder.IsAssigned).IsFalse(); + await Assert.That(holder.IsDisposed).IsFalse(); + holder.Disposable = inner; + await Assert.That(holder.IsAssigned).IsTrue(); + await Assert.That(holder.Disposable).IsSameReferenceAs(inner); + + holder.Dispose(); + await Assert.That(holder.IsDisposed).IsTrue(); + await Assert.That(inner.DisposeCount).IsEqualTo(1); + } + + /// Verifies that a second non-null assignment throws. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnceDisposableAssignedTwice_ThenThrows() + { + var holder = new OnceDisposable { Disposable = new CountingDisposable() }; + + var ex = Assert.Throws(() => holder.Disposable = new CountingDisposable()); + await Assert.That(ex).IsNotNull(); + } + + /// Verifies that assigning after dispose disposes the supplied value and reports null via the getter. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnceDisposableAfterDispose_ThenAssignmentDisposesValueAndGetterReportsNull() + { + var holder = new OnceDisposable(); + holder.Dispose(); + + var late = new CountingDisposable(); + holder.Disposable = late; + + await Assert.That(late.DisposeCount).IsEqualTo(1); + await Assert.That(holder.Disposable).IsNull(); + } + + /// Verifies that disposing without ever assigning is a no-op. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnceDisposableDisposedUnassigned_ThenNoOp() + { + var holder = new OnceDisposable(); + holder.Dispose(); + + await Assert.That(holder.Disposable).IsNull(); + } + + /// Counts how many times is invoked. + 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.Primitives.Extensions.Tests/Internal/MutableDisposableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/MutableDisposableTests.cs new file mode 100644 index 0000000..ecaa042 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/MutableDisposableTests.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. + +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.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++); + + await Assert.That(holder.IsDisposed).IsFalse(); + + holder.Dispose(); + holder.Dispose(); + + await Assert.That(holder.IsDisposed).IsTrue(); + await Assert.That(disposed).IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ObserverArrayHelpersTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ObserverArrayHelpersTests.cs new file mode 100644 index 0000000..6582b39 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.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.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Tests.Internal; + +/// Direct RxVoid 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.Primitives.Extensions.Tests/Internal/ReduceSinkStateTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ReduceSinkStateTests.cs new file mode 100644 index 0000000..f29e7a8 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ReduceSinkStateTests.cs @@ -0,0 +1,146 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Tests.Internal; + +/// Tests for , the shared state object used by +/// the synchronous combine-then-reduce operator family. +public class ReduceSinkStateTests +{ + /// Verifies that a freshly-constructed state reports zero values seen and is not terminal. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFreshState_ThenZeroValuesAndNotTerminal() + { + const int SourceCount = 3; + var observer = new CaptureObserver(); + var state = new ReduceSinkState(observer, SourceCount); + + await Assert.That(state.HasValueCount).IsEqualTo(0); + await Assert.That(state.CompletedCount).IsEqualTo(0); + await Assert.That(state.IsDone).IsFalse(); + await Assert.That(state.AllValuesPresent).IsFalse(); + await Assert.That(state.Values).Count().IsEqualTo(SourceCount); + await Assert.That(state.Completed).Count().IsEqualTo(SourceCount); + } + + /// Verifies that AllValuesPresent flips once every slot has a value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenEverySlotPopulated_ThenAllValuesPresent() + { + const int FirstSeed = 1; + const int SecondSeed = 2; + var observer = new CaptureObserver(); + var state = new ReduceSinkState(observer, count: 2); + + state.Values[0] = FirstSeed; + state.HasValueCount++; + await Assert.That(state.AllValuesPresent).IsFalse(); + + state.Values[1] = SecondSeed; + state.HasValueCount++; + await Assert.That(state.AllValuesPresent).IsTrue(); + } + + /// Verifies that HandleError forwards once, marks terminal, and is idempotent. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenHandleError_ThenForwardsAndIsTerminal() + { + var observer = new CaptureObserver(); + var state = new ReduceSinkState(observer, count: 2); + var error = new InvalidOperationException("boom"); + + state.HandleError(error); + state.HandleError(new InvalidOperationException("second")); // should be no-op + + await Assert.That(state.IsDone).IsTrue(); + await Assert.That(observer.Errors).Count().IsEqualTo(1); + await Assert.That(observer.Errors[0]).IsEqualTo(error); + } + + /// Verifies that HandleCompleted completes once every source has completed. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAllSourcesComplete_ThenDownstreamCompletes() + { + const int SeedValue1 = 1; + const int SeedValue2 = 2; + const int SeededValueCount = 2; + var observer = new CaptureObserver(); + var state = new ReduceSinkState(observer, count: 2); + + // Seed both values so completion-without-value path isn't triggered. + state.Values[0] = SeedValue1; + state.Values[1] = SeedValue2; + state.HasValueCount = SeededValueCount; + + state.HandleCompleted(0); + await Assert.That(state.IsDone).IsFalse(); + await Assert.That(observer.Completed).IsFalse(); + + state.HandleCompleted(1); + await Assert.That(state.IsDone).IsTrue(); + await Assert.That(observer.Completed).IsTrue(); + } + + /// Verifies that a source completing without ever emitting closes the combined sequence. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceCompletesWithoutValue_ThenDownstreamCompletesEarly() + { + const int FirstSeed = 1; + var observer = new CaptureObserver(); + var state = new ReduceSinkState(observer, count: 2); + + // Source 0 emitted; source 1 completes without a value. + state.Values[0] = FirstSeed; + state.HasValueCount = 1; + + state.HandleCompleted(1); + + await Assert.That(state.IsDone).IsTrue(); + await Assert.That(observer.Completed).IsTrue(); + } + + /// Verifies that HandleCompleted is idempotent per-source-index. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSameSourceCompletesTwice_ThenSecondCallIgnored() + { + var observer = new CaptureObserver(); + var state = new ReduceSinkState(observer, count: 2); + + state.HandleCompleted(0); + state.HandleCompleted(0); // same index — should be no-op + + await Assert.That(state.CompletedCount).IsEqualTo(1); + } + + /// Tiny capture observer used by the helper tests. + /// Captured element type. + private sealed class CaptureObserver : IObserver + { + /// Gets the captured OnNext values in order. + public List Values { get; } = []; + + /// Gets the captured OnError exceptions in order. + public List Errors { get; } = []; + + /// Gets a value indicating whether OnCompleted was observed. + public bool Completed { get; private set; } + + /// + public void OnNext(T value) => Values.Add(value); + + /// + public void OnError(Exception error) => Errors.Add(error); + + /// + public void OnCompleted() => Completed = true; + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/SequencerPeriodicMixinsTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/SequencerPeriodicMixinsTests.cs new file mode 100644 index 0000000..5d68953 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/SequencerPeriodicMixinsTests.cs @@ -0,0 +1,38 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Tests.Internal; + +/// Tests for periodic sequencer scheduling helpers. +public class SequencerPeriodicMixinsTests +{ + /// Period used by virtual-clock tests. + private static readonly TimeSpan Period = TimeSpan.FromTicks(10); + + /// Verifies repeated disposal and a tick observed after disposal are both no-ops. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenPeriodicSubscriptionDisposedTwiceAndTickRuns_ThenNoOp() + { + var scheduler = new VirtualClock(); + var ticks = 0; + var subscription = scheduler.SchedulePeriodic(Period, Period, () => ticks++); + + const int PeriodsToAdvance = 3; + subscription.Dispose(); + subscription.Dispose(); + InvokeTick(subscription); + scheduler.AdvanceBy(TimeSpan.FromTicks(Period.Ticks * PeriodsToAdvance)); + + await Assert.That(ticks).IsEqualTo(0); + } + + /// Invokes the tick method to exercise the disposed tick guard. + /// The subscription under test. + private static void InvokeTick(IDisposable subscription) => + ((SequencerPeriodicMixins.PeriodicSubscription)subscription).Tick(); +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/SwapDisposableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/SwapDisposableTests.cs new file mode 100644 index 0000000..091f7cc --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/SwapDisposableTests.cs @@ -0,0 +1,73 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.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++); + + await Assert.That(holder.IsDisposed).IsFalse(); + + holder.Dispose(); + holder.Dispose(); + + await Assert.That(holder.IsDisposed).IsTrue(); + await Assert.That(disposed).IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/TimerSinkStateTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/TimerSinkStateTests.cs new file mode 100644 index 0000000..f991e18 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/TimerSinkStateTests.cs @@ -0,0 +1,112 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Tests.Internal; + +/// Direct RxVoid tests for — covers the terminal +/// idempotency guards across HandleError, HandleCompleted, and HandleDispose. +public class TimerSinkStateTests +{ + /// Verifies HandleError forwards the error then marks the state done. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenHandleError_ThenForwardsAndMarksDone() + { + var observer = new RecordingObserver(); + var state = new TimerSinkState(observer); + var expected = new InvalidOperationException("timer-error"); + + state.HandleError(expected); + + await Assert.That(state.Done).IsTrue(); + await Assert.That(observer.Errors).Count().IsEqualTo(1); + await Assert.That(observer.Errors[0]).IsSameReferenceAs(expected); + } + + /// Verifies HandleCompleted forwards completion then marks the state done. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenHandleCompleted_ThenForwardsAndMarksDone() + { + var observer = new RecordingObserver(); + var state = new TimerSinkState(observer); + + state.HandleCompleted(); + + await Assert.That(state.Done).IsTrue(); + await Assert.That(observer.Completions).IsEqualTo(1); + } + + /// Exercises the HandleCompleted idempotency guard — once the state is + /// already terminal, a second call returns without re-forwarding to the downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenHandleCompletedAfterError_ThenNoOp() + { + var observer = new RecordingObserver(); + var state = new TimerSinkState(observer); + + state.HandleError(new InvalidOperationException("first")); + state.HandleCompleted(); + + await Assert.That(observer.Completions).IsEqualTo(0); + await Assert.That(observer.Errors).Count().IsEqualTo(1); + } + + /// Exercises the HandleError idempotency guard — once the state is + /// already terminal, a second call returns without re-forwarding. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenHandleErrorAfterCompleted_ThenNoOp() + { + var observer = new RecordingObserver(); + var state = new TimerSinkState(observer); + + state.HandleCompleted(); + state.HandleError(new InvalidOperationException("second")); + + await Assert.That(observer.Errors).IsEmpty(); + await Assert.That(observer.Completions).IsEqualTo(1); + } + + /// Verifies HandleDispose marks the state done without forwarding any + /// notification to the downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenHandleDispose_ThenMarksDoneWithoutForwarding() + { + var observer = new RecordingObserver(); + var state = new TimerSinkState(observer); + + state.HandleDispose(); + + await Assert.That(state.Done).IsTrue(); + await Assert.That(observer.Errors).IsEmpty(); + await Assert.That(observer.Completions).IsEqualTo(0); + } + + /// Recording observer used by the direct TimerSinkState tests. + /// The element type. + private sealed class RecordingObserver : IObserver + { + /// Gets the captured errors. + public List Errors { get; } = []; + + /// Gets the number of OnCompleted calls observed. + public int Completions { get; private set; } + + /// + public void OnNext(T value) + { + } + + /// + public void OnError(Exception error) => Errors.Add(error); + + /// + public void OnCompleted() => Completions++; + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservableSubscriptionExtensionsTests.SchedulerOverloads.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservableSubscriptionExtensionsTests.SchedulerOverloads.cs new file mode 100644 index 0000000..8a3cc57 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservableSubscriptionExtensionsTests.SchedulerOverloads.cs @@ -0,0 +1,102 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Linq; +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// Coverage for the scheduler-routed overloads of the WaitFor* helpers on +/// . The non-scheduler overloads are +/// already covered in the companion file; these dispatch the subscribe call via a +/// scheduler before blocking. +public partial class ObservableSubscriptionExtensionsTests +{ + /// Sentinel value emitted by single-value scheduler tests. + private const int SchedulerSentinelValue = 13; + + /// Default test timeout for the scheduler-routed overloads. + private static readonly TimeSpan SchedulerWaitTimeout = TimeSpan.FromSeconds(5); + + /// Verifies that WaitForValue with a scheduler returns the emitted value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitForValueWithSchedulerOnly_ThenReturnsEmittedValue() + { + var result = Observable.Return(SchedulerSentinelValue) + .WaitForValue(TaskPoolSequencer.Default); + + await Assert.That(result).IsEqualTo(SchedulerSentinelValue); + } + + /// Verifies that WaitForValue with a scheduler and explicit timeout returns the emitted value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitForValueWithSchedulerAndTimeout_ThenReturnsEmittedValue() + { + var result = Observable.Return(SchedulerSentinelValue) + .WaitForValue(TaskPoolSequencer.Default, SchedulerWaitTimeout); + + await Assert.That(result).IsEqualTo(SchedulerSentinelValue); + } + + /// Verifies that the scheduler+timeout form of WaitForValue times out for a never-terminating source. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitForValueWithSchedulerTimesOut_ThenTimeoutException() + { + Action call = () => Observable.Never() + .WaitForValue(TaskPoolSequencer.Default, TimeSpan.FromMilliseconds(50)); + var ex = Assert.Throws(call); + await Assert.That(ex).IsNotNull(); + } + + /// Verifies that WaitForCompletion with a scheduler returns after terminal. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitForCompletionWithSchedulerOnly_ThenReturnsAfterTerminal() + { + Observable.Return(RxVoid.Default) + .WaitForCompletion(TaskPoolSequencer.Default); + + // Sentinel follow-up to give TUnit a real assertion. + var sentinel = Observable.Return(SchedulerSentinelValue).SubscribeGetValue(); + await Assert.That(sentinel).IsEqualTo(SchedulerSentinelValue); + } + + /// Verifies that WaitForCompletion with a scheduler and explicit timeout returns after terminal. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitForCompletionWithSchedulerAndTimeout_ThenReturnsAfterTerminal() + { + Observable.Return(RxVoid.Default) + .WaitForCompletion(TaskPoolSequencer.Default, SchedulerWaitTimeout); + + var sentinel = Observable.Return(SchedulerSentinelValue).SubscribeGetValue(); + await Assert.That(sentinel).IsEqualTo(SchedulerSentinelValue); + } + + /// Verifies that WaitForError with a scheduler returns null for a normal completion. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitForErrorWithSchedulerOnly_ThenReturnsNullOnNormalCompletion() + { + var error = Observable.Return(SchedulerSentinelValue) + .WaitForError(TaskPoolSequencer.Default); + + await Assert.That(error).IsNull(); + } + + /// Verifies that WaitForError with a scheduler and timeout captures the error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitForErrorWithSchedulerAndTimeout_ThenCapturesError() + { + var expected = new InvalidOperationException("scheduler-captured"); + var error = Observable.Throw(expected) + .WaitForError(TaskPoolSequencer.Default, SchedulerWaitTimeout); + + await Assert.That(error).IsEqualTo(expected); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservableSubscriptionExtensionsTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservableSubscriptionExtensionsTests.cs new file mode 100644 index 0000000..0e2d393 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservableSubscriptionExtensionsTests.cs @@ -0,0 +1,247 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Linq; +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// Tests for the blocking subscribe helpers on . +public partial class ObservableSubscriptionExtensionsTests +{ + /// Sentinel value emitted by single-value tests. + private const int SentinelValue = 7; + + /// Verifies that SubscribeGetValue returns the last synchronously-emitted value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscribeGetValue_ThenReturnsLastSyncValue() + { + var result = Observable.Return(SentinelValue).SubscribeGetValue(); + + await Assert.That(result).IsEqualTo(SentinelValue); + } + + /// Verifies that SubscribeGetValue returns the default when the sequence is empty. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscribeGetValueEmpty_ThenReturnsDefault() + { + var result = Observable.Empty().SubscribeGetValue(); + + await Assert.That(result).IsEqualTo(0); + } + + /// Verifies that SubscribeAndComplete consumes a RxVoid-producing observable without error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscribeAndComplete_ThenSwallowsUnitAndReturns() + { + // Helper is fire-and-forget; verify a follow-up call on a different sequence still + // returns the expected value, proving SubscribeAndComplete didn't leave state behind. + Observable.Return(RxVoid.Default).SubscribeAndComplete(); + var followUp = Observable.Return(RxVoid.Default).SubscribeGetValue(); + await Assert.That(followUp).IsEqualTo(RxVoid.Default); + } + + /// Verifies that SubscribeGetError captures a synchronous error and returns it. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscribeGetError_ThenCapturesSyncError() + { + var expected = new InvalidOperationException("sync"); + var error = Observable.Throw(expected).SubscribeGetError(); + + await Assert.That(error).IsEqualTo(expected); + } + + /// Verifies that the RxVoid-overload of SubscribeGetError captures the error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscribeGetErrorUnit_ThenCapturesSyncError() + { + var expected = new InvalidOperationException("RxVoid-sync"); + var error = Observable.Throw(expected).SubscribeGetError(); + + await Assert.That(error).IsEqualTo(expected); + } + + /// Verifies that WaitForValue blocks until the synchronously-completing source emits. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitForValue_ThenReturnsEmittedValue() + { + var result = Observable.Return(SentinelValue).WaitForValue(); + + await Assert.That(result).IsEqualTo(SentinelValue); + } + + /// Verifies that the timeout overload of WaitForValue honours an explicit deadline. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitForValueWithTimeout_ThenReturnsEmittedValue() + { + var result = Observable.Return(SentinelValue).WaitForValue(TimeSpan.FromSeconds(5)); + + await Assert.That(result).IsEqualTo(SentinelValue); + } + + /// Verifies that WaitForValue throws on a non-terminating source. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitForValueTimesOut_ThenTimeoutException() + { + Action call = () => Observable.Never().WaitForValue(TimeSpan.FromMilliseconds(50)); + var ex = Assert.Throws(call); + await Assert.That(ex).IsNotNull(); + } + + /// Verifies that WaitForCompletion returns once the RxVoid-producing source completes. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitForCompletion_ThenReturnsAfterTerminal() + { + // Helper returns void on success; the absence of TimeoutException after a synchronous + // completion is the contract under test. Use the value-returning sibling for the actual + // assertion so TUnit has a real check. + Observable.Return(RxVoid.Default).WaitForCompletion(TimeSpan.FromSeconds(5)); + var subsequent = Observable.Return(RxVoid.Default).SubscribeGetValue(); + await Assert.That(subsequent).IsEqualTo(RxVoid.Default); + } + + /// Verifies that WaitForCompletion rethrows the source's error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitForCompletionWithError_ThenRethrows() + { + var expected = new InvalidOperationException("wait"); + Action call = () => Observable.Throw(expected).WaitForCompletion(TimeSpan.FromSeconds(5)); + var ex = Assert.Throws(call); + await Assert.That(ex).IsEqualTo(expected); + } + + /// Verifies that WaitForCompletion throws for a non-terminating source. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitForCompletionTimesOut_ThenTimeoutException() + { + Action call = () => Observable.Never().WaitForCompletion(TimeSpan.FromMilliseconds(50)); + var ex = Assert.Throws(call); + await Assert.That(ex).IsNotNull(); + } + + /// Verifies that WaitForError returns null when the source completes normally. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitForErrorNormalCompletion_ThenReturnsNull() + { + var error = Observable.Return(SentinelValue).WaitForError(TimeSpan.FromSeconds(5)); + + await Assert.That(error).IsNull(); + } + + /// Verifies the default WaitForError overload returns null on normal completion. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitForErrorDefaultNormalCompletion_ThenReturnsNull() + { + var error = Observable.Return(SentinelValue).WaitForError(); + + await Assert.That(error).IsNull(); + } + + /// Verifies that WaitForError returns the captured error rather than rethrowing. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitForErrorSourceErrors_ThenReturnsCapturedError() + { + var expected = new InvalidOperationException("captured"); + var error = Observable.Throw(expected).WaitForError(TimeSpan.FromSeconds(5)); + + await Assert.That(error).IsEqualTo(expected); + } + + /// Verifies the default WaitForError overload returns the captured source error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitForErrorDefaultSourceErrors_ThenReturnsCapturedError() + { + var expected = new InvalidOperationException("captured-default"); + var error = Observable.Throw(expected).WaitForError(); + + await Assert.That(error).IsEqualTo(expected); + } + + /// Verifies that WaitForError throws for a non-terminating source. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitForErrorTimesOut_ThenTimeoutException() + { + Action call = () => Observable.Never().WaitForError(TimeSpan.FromMilliseconds(50)); + var ex = Assert.Throws(call); + await Assert.That(ex).IsNotNull(); + } + + /// Verifies the single-arg WaitForCompletion(IObservable<RxVoid>) 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(RxVoid.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.Primitives.Extensions.Tests/ObservablesTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservablesTests.cs new file mode 100644 index 0000000..5e0b760 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservablesTests.cs @@ -0,0 +1,24 @@ +// 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.Primitives.Extensions.Tests; + +/// Tests for the factory methods. +public class ObservablesTests +{ + /// Verifies emits the single value and completes on subscribe. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenReturn_ThenEmitsValueAndCompletes() + { + const int Value = 42; + var values = new List(); + var completed = false; + + using var sub = Observables.Return(Value).Subscribe(values.Add, () => completed = true); + + await Assert.That(values).IsCollectionEqualTo([Value]); + await Assert.That(completed).IsTrue(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/BooleanReduceObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/BooleanReduceObservableTests.cs new file mode 100644 index 0000000..c07d315 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/BooleanReduceObservableTests.cs @@ -0,0 +1,254 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Edge-case coverage for the boolean-reduce operators backed by +/// BooleanReduceObservable — empty-source short-circuit, partial-value +/// suppression, target match/mismatch, error broadcast. +public class BooleanReduceObservableTests +{ + /// Synthetic error message attached to source errors. + private const string SourceErrorMessage = "source error"; + + /// Verifies that an empty input emits a single true and completes. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCombineLatestValuesAreAllTrueWithEmptySources_ThenEmitsTrueAndCompletes() + { + var results = new List(); + var completed = false; + + using var sub = Array.Empty>() + .CombineLatestValuesAreAllTrue() + .Subscribe(results.Add, () => completed = true); + + await Assert.That(results).IsCollectionEqualTo([true]); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that partial sources do not emit until every source has a value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAllTruePartialSources_ThenSuppressesEmission() + { + var a = new Subject(); + var b = new Subject(); + var results = new List(); + + using var sub = new IObservable[] { a, b } + .CombineLatestValuesAreAllTrue() + .Subscribe(results.Add); + + a.OnNext(true); + + await Assert.That(results).IsEmpty(); + } + + /// Verifies that the operator emits true only when every latest value is true. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAllTrueTransitions_ThenEmitsExpectedSequence() + { + var a = new Subject(); + var b = new Subject(); + var results = new List(); + + using var sub = new IObservable[] { a, b } + .CombineLatestValuesAreAllTrue() + .Subscribe(results.Add); + + a.OnNext(true); + b.OnNext(false); + b.OnNext(true); + a.OnNext(false); + + await Assert.That(results).IsCollectionEqualTo([false, true, false]); + } + + /// Verifies that AllFalse emits true only when every latest value is false. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAllFalseTransitions_ThenEmitsExpectedSequence() + { + var a = new Subject(); + var b = new Subject(); + var results = new List(); + + using var sub = new IObservable[] { a, b } + .CombineLatestValuesAreAllFalse() + .Subscribe(results.Add); + + a.OnNext(false); + b.OnNext(false); + b.OnNext(true); + + await Assert.That(results).IsCollectionEqualTo([true, false]); + } + + /// Verifies that a source error propagates downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAllTrueSourceErrors_ThenForwardsError() + { + var a = new Subject(); + var b = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = new IObservable[] { a, b } + .CombineLatestValuesAreAllTrue() + .Subscribe(static _ => { }, ex => caught = ex); + + a.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies collection-only source materialization uses . + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourcesAreCollectionOnly_ThenMaterializedByCopyTo() + { + using var a = new BehaviorSubject(true); + using var b = new BehaviorSubject(true); + var sources = new CollectionOnlySources([a.AsObservable(), b.AsObservable()]); + var results = new List(); + + using var sub = sources.CombineLatestValuesAreAllTrue().Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo([true]); + } + + /// Verifies the non-collection materialization path returns the exact-size buffer. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenNonCollectionSourcesExactlyFillBuffer_ThenUsesBufferDirectly() + { + using var a = new BehaviorSubject(true); + using var b = new BehaviorSubject(true); + using var c = new BehaviorSubject(true); + using var d = new BehaviorSubject(true); + var results = new List(); + + using var sub = Enumerate(a, b, c, d) + .CombineLatestValuesAreAllTrue() + .Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo([true]); + + static IEnumerable> Enumerate(params BehaviorSubject[] subjects) + { + for (var i = 0; i < subjects.Length; i++) + { + yield return subjects[i].AsObservable(); + } + } + } + + /// Verifies non-collection materialization grows beyond the initial buffer. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenNonCollectionSourcesExceedInitialBuffer_ThenGrowsBuffer() + { + using var a = new BehaviorSubject(true); + using var b = new BehaviorSubject(true); + using var c = new BehaviorSubject(true); + using var d = new BehaviorSubject(true); + using var e = new BehaviorSubject(true); + var results = new List(); + + using var sub = Enumerate(a, b, c, d, e) + .CombineLatestValuesAreAllTrue() + .Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo([true]); + + static IEnumerable> Enumerate(params BehaviorSubject[] subjects) + { + for (var i = 0; i < subjects.Length; i++) + { + yield return subjects[i].AsObservable(); + } + } + } + + /// 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(); + } + + /// Collection implementation that deliberately avoids . + /// The source items. + private sealed class CollectionOnlySources(IObservable[] items) : ICollection> + { + /// + public int Count => items.Length; + + /// + public bool IsReadOnly => true; + + /// + public void Add(IObservable item) => throw new NotSupportedException(); + + /// + public void Clear() => throw new NotSupportedException(); + + /// + public bool Contains(IObservable item) => Array.IndexOf(items, item) >= 0; + + /// + public void CopyTo(IObservable[] array, int arrayIndex) => Array.Copy(items, 0, array, arrayIndex, items.Length); + + /// + public bool Remove(IObservable item) => throw new NotSupportedException(); + + /// + public IEnumerator> GetEnumerator() => ((IEnumerable>)items).GetEnumerator(); + + /// + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/CatchAndReturnWithFactoryObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/CatchAndReturnWithFactoryObservableTests.cs new file mode 100644 index 0000000..c3ee11e --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/CatchAndReturnWithFactoryObservableTests.cs @@ -0,0 +1,106 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Edge-case coverage for the factory overload of CatchAndReturn +/// backed by CatchAndReturnWithFactoryObservable<T, TException> — +/// matching exception path, non-matching exception passthrough, and +/// factory-error propagation. +public class CatchAndReturnWithFactoryObservableTests +{ + /// Synthetic error message attached to source errors. + private const string SourceErrorMessage = "matching"; + + /// Synthetic error message attached to mismatched source errors. + private const string MismatchedErrorMessage = "different type"; + + /// Synthetic error message attached to factory failures. + private const string FactoryFailedMessage = "factory failed"; + + /// Length used to build a fallback from the captured exception message. + private const int FallbackBaseValue = 100; + + /// Verifies that a matching exception emits a fallback from the factory and completes. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCatchAndReturnMatchingException_ThenEmitsFactoryFallbackAndCompletes() + { + var subject = new Subject(); + var results = new List(); + var completed = false; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.CatchAndReturn( + ex => FallbackBaseValue + ex.Message.Length) + .Subscribe(results.Add, () => completed = true); + + subject.OnError(expected); + + await Assert.That(results).IsCollectionEqualTo([FallbackBaseValue + SourceErrorMessage.Length]); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that a non-matching exception passes through to OnError. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCatchAndReturnNonMatchingException_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new ArgumentException(MismatchedErrorMessage); + + using var sub = subject.CatchAndReturn( + static _ => FallbackBaseValue) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that an exception thrown by the fallback factory replaces the original error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCatchAndReturnFactoryThrows_ThenForwardsFactoryError() + { + var subject = new Subject(); + Exception? caught = null; + var sourceError = new InvalidOperationException(SourceErrorMessage); + var factoryError = new InvalidOperationException(FactoryFailedMessage); + + using var sub = subject.CatchAndReturn( + _ => throw factoryError) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(sourceError); + + await Assert.That(caught).IsSameReferenceAs(factoryError); + } + + /// Verifies that OnNext values pass through unchanged when no error occurs. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCatchAndReturnNoError_ThenPassesValuesThrough() + { + const int First = 1; + const int Second = 2; + var subject = new Subject(); + var results = new List(); + var completed = false; + + using var sub = subject.CatchAndReturn( + static _ => FallbackBaseValue) + .Subscribe(results.Add, () => completed = true); + + subject.OnNext(First); + subject.OnNext(Second); + subject.OnCompleted(); + + await Assert.That(results).IsCollectionEqualTo([First, Second]); + await Assert.That(completed).IsTrue(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/CatchIgnoreObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/CatchIgnoreObservableTests.cs new file mode 100644 index 0000000..bc4eb7e --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/CatchIgnoreObservableTests.cs @@ -0,0 +1,93 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Edge-case coverage for CatchIgnore<TSource, TException> backed by +/// CatchIgnoreObservable<TSource, TException> — exception filtering and the +/// action-throws branch. +public class CatchIgnoreObservableTests +{ + /// Verifies that CatchIgnore invokes the action and completes on a matching exception. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCatchIgnoreMatchesException_ThenInvokesActionAndCompletes() + { + var subject = new Subject(); + Exception? observed = null; + var completed = false; + var expected = new InvalidOperationException("caught"); + + using var sub = subject.CatchIgnore(ex => observed = ex) + .Subscribe( + static _ => { }, + static _ => Assert.Fail("OnError should not run when ignored."), + () => completed = true); + + subject.OnError(expected); + + await Assert.That(observed).IsSameReferenceAs(expected); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that CatchIgnore forwards non-matching exceptions. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCatchIgnoreDoesNotMatchException_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var actionInvocations = 0; + var expected = new ArgumentException("not matching"); + + using var sub = subject.CatchIgnore(_ => actionInvocations++) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(actionInvocations).IsEqualTo(0); + } + + /// Verifies that when the catch action itself throws, the new exception is forwarded. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCatchIgnoreActionThrows_ThenForwardsActionException() + { + var subject = new Subject(); + Exception? caught = null; + var fromAction = new InvalidOperationException("action failed"); + + using var sub = subject.CatchIgnore(_ => throw fromAction) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(new InvalidOperationException("original")); + + await Assert.That(caught).IsSameReferenceAs(fromAction); + } + + /// Verifies that values arriving before the error are forwarded unchanged. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCatchIgnoreSourceEmitsValues_ThenForwardedThenCompletedOnIgnoredError() + { + const int FirstValue = 1; + const int SecondValue = 2; + var subject = new Subject(); + var values = new List(); + var completed = false; + + using var sub = subject.CatchIgnore(static _ => { }) + .Subscribe(values.Add, () => completed = true); + + subject.OnNext(FirstValue); + subject.OnNext(SecondValue); + subject.OnError(new InvalidOperationException("swallowed")); + + await Assert.That(values).IsCollectionEqualTo([FirstValue, SecondValue]); + await Assert.That(completed).IsTrue(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ConflateObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ConflateObservableTests.cs new file mode 100644 index 0000000..460c059 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ConflateObservableTests.cs @@ -0,0 +1,270 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Extensions.Operators; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Edge-case coverage for the Conflate operator backed by +/// ConflateObservable<T> — source-error path through the scheduler +/// marshaller, completion-while-throttled, fast-path interruption by a newer value, +/// and dispose mid-drain. +public class ConflateObservableTests +{ + /// Synthetic error message attached to source errors. + private const string SourceErrorMessage = "source error"; + + /// 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; + + /// Sentinel values. + private const int First = 1; + + /// Second sentinel value. + private const int Second = 2; + + /// Third sentinel value. + private const int Third = 3; + + /// Verifies that a source error is forwarded through the scheduler marshaller. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenConflateSourceErrors_ThenForwardsError() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.Conflate(TimeSpan.FromTicks(UpdatePeriodTicks), scheduler) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + scheduler.AdvanceBy(UpdatePeriodTicks * SettleMultiplier); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that a newer value arriving inside the throttle window replaces the + /// pending scheduled emission rather than emitting both. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenConflateNewerValueDuringThrottle_ThenReplacesPending() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + + using var sub = subject.Conflate(TimeSpan.FromTicks(UpdatePeriodTicks), scheduler) + .Subscribe(results.Add); + + subject.OnNext(First); + scheduler.AdvanceBy(HalfWindowTicks); + subject.OnNext(Second); + scheduler.AdvanceBy(HalfWindowTicks); + subject.OnNext(Third); + scheduler.AdvanceBy(UpdatePeriodTicks * SettleMultiplier); + + // Inside the throttle window: the first pending value is replaced by the newer one. + await Assert.That(results.Count).IsGreaterThanOrEqualTo(1); + await Assert.That(results).DoesNotContain(First); + await Assert.That(results).Contains(Second); + } + + /// Verifies that completion before any throttled emission flushes through. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenConflateCompletesBeforeFirstEmission_ThenCompletes() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var completed = false; + + using var sub = subject.Conflate(TimeSpan.FromTicks(UpdatePeriodTicks), scheduler) + .Subscribe(static _ => { }, () => completed = true); + + subject.OnCompleted(); + scheduler.AdvanceBy(UpdatePeriodTicks); + + await Assert.That(completed).IsTrue(); + } + + /// Verifies that disposing before the scheduled emission fires suppresses the value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenConflateDisposedBeforeScheduledEmission_ThenSuppressed() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + + var sub = subject.Conflate(TimeSpan.FromTicks(UpdatePeriodTicks), scheduler) + .Subscribe(results.Add); + + subject.OnNext(First); + scheduler.AdvanceBy(HalfWindowTicks); + subject.OnNext(Second); + sub.Dispose(); + scheduler.AdvanceBy(UpdatePeriodTicks); + + // Initial value may or may not have fired before disposal but no late emission must arrive. + var snapshot = results.Count; + 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 VirtualClock(); + 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 VirtualClock(); + 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 VirtualClock(); + 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 sink directly, disposing it, and then + /// pushing notifications — exercising the defensive branch that is otherwise unreachable + /// through the front-door Conflate pipeline. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSinkEnqueuedAfterDispose_ThenSilentlyDropped() + { + var downstream = new RecordingObserver(); + var scheduler = new VirtualClock(); + var sink = new ConflateObservable.ConflateSink( + downstream, + TimeSpan.FromTicks(UpdatePeriodTicks), + scheduler); + + sink.Dispose(); + sink.OnNext(1); + sink.OnError(new InvalidOperationException("late")); + sink.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 VirtualClock(); + var sink = new ConflateObservable.ConflateSink( + downstream, + TimeSpan.FromTicks(UpdatePeriodTicks), + scheduler); + + var expected = new InvalidOperationException("first"); + sink.OnError(expected); + scheduler.AdvanceBy(UpdatePeriodTicks); + + sink.OnNext(1); + sink.OnError(new InvalidOperationException("ignored")); + sink.OnCompleted(); + scheduler.AdvanceBy(UpdatePeriodTicks); + + 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.Primitives.Extensions.Tests/Operators/DebounceImmediateObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DebounceImmediateObservableTests.cs new file mode 100644 index 0000000..0edc5ec --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.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 ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.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 VirtualClock(); + 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 VirtualClock(); + 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 VirtualClock(); + 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.Primitives.Extensions.Tests/Operators/DetectStaleObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DetectStaleObservableTests.cs new file mode 100644 index 0000000..22e7518 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DetectStaleObservableTests.cs @@ -0,0 +1,54 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Disposables; +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Coverage for DetectStaleObservable's subscription-teardown branch — when the source +/// terminates synchronously during subscribe, the sink is already done by the time the upstream handle +/// is attached, so the attach disposes it instead of recording it. +public class DetectStaleObservableTests +{ + /// Staleness window used by the tests. + private const int WindowTicks = 100; + + /// Synthetic error message attached to source errors. + private const string SourceErrorMessage = "source error"; + + /// Verifies that a source erroring synchronously during subscribe forwards the error and + /// disposes the upstream handle through the attach-after-terminated branch. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceTerminatesDuringSubscribe_ThenLateAttachDisposesSubscription() + { + var scheduler = new VirtualClock(); + var expected = new InvalidOperationException(SourceErrorMessage); + var source = new SyncErroringObservable(expected); + Exception? caught = null; + + using var sub = source.DetectStale(TimeSpan.FromTicks(WindowTicks), scheduler) + .Subscribe(static _ => { }, ex => caught = ex); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(source.Subscription.IsDisposed).IsTrue(); + } + + /// Observable that synchronously errors during Subscribe and exposes the subscription + /// handle it returned so tests can assert it was disposed. + /// The element type. + private sealed class SyncErroringObservable(Exception error) : IObservable + { + /// Gets the subscription handle returned from the most recent subscribe. + public BooleanDisposable Subscription { get; } = new(); + + /// + public IDisposable Subscribe(IObserver observer) + { + observer.OnError(error); + return Subscription; + } + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DoOnDisposeObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DoOnDisposeObservableTests.cs new file mode 100644 index 0000000..a721056 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DoOnDisposeObservableTests.cs @@ -0,0 +1,31 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Coverage for DoOnDisposeObservable — the dispose action fires exactly once even when +/// the subscription is disposed multiple times, and the upstream is torn down before the action runs. +public class DoOnDisposeObservableTests +{ + /// Verifies the dispose action fires once and the upstream is detached on first dispose, and a + /// second dispose is a no-op via the latch. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDisposedTwice_ThenActionFiresOnce() + { + var executed = 0; + var source = new Subject(); + + var sub = source.DoOnDispose(() => executed++).Subscribe(); + await Assert.That(source.HasObservers).IsTrue(); + + sub.Dispose(); + sub.Dispose(); + + await Assert.That(executed).IsEqualTo(1); + await Assert.That(source.HasObservers).IsFalse(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DropIfBusyObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DropIfBusyObservableTests.cs new file mode 100644 index 0000000..8b130d0 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DropIfBusyObservableTests.cs @@ -0,0 +1,102 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Edge-case coverage for DropIfBusyObservable<T>. +public class DropIfBusyObservableTests +{ + /// Delay used to let fire-and-forget async continuations settle. + private const int SettleDelayMilliseconds = 50; + + /// Verifies a handler completion after source completion does not emit the value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenHandlerCompletesAfterSourceDone_ThenValueDropped() + { + var subject = new Subject(); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var values = new List(); + var completed = false; + + using var sub = subject.DropIfBusy(async _ => await release.Task.ConfigureAwait(false)) + .Subscribe(values.Add, () => completed = true); + + subject.OnNext(1); + subject.OnCompleted(); + release.SetResult(); + await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + + await Assert.That(values).IsEmpty(); + await Assert.That(completed).IsTrue(); + } + + /// Verifies a handler fault after source completion does not report an error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenHandlerThrowsAfterSourceDone_ThenErrorDropped() + { + var subject = new Subject(); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var expected = new InvalidOperationException("late-handler"); + Exception? caught = null; + var completed = false; + + using var sub = subject.DropIfBusy(async _ => + { + await release.Task.ConfigureAwait(false); + throw expected; + }).Subscribe(static _ => { }, ex => caught = ex, () => completed = true); + + subject.OnNext(1); + subject.OnCompleted(); + release.SetResult(); + await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + + await Assert.That(caught).IsNull(); + await Assert.That(completed).IsTrue(); + } + + /// Verifies a source error before termination is forwarded downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceErrorsBeforeDone_ThenForwardsError() + { + var subject = new Subject(); + var expected = new InvalidOperationException("source-error"); + Exception? caught = null; + + using var sub = subject.DropIfBusy(static _ => default) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies a handler fault before termination is forwarded downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenHandlerThrowsBeforeDone_ThenForwardsError() + { + var subject = new Subject(); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var expected = new InvalidOperationException("handler"); + Exception? caught = null; + + using var sub = subject.DropIfBusy(async _ => + { + await release.Task.ConfigureAwait(false); + throw expected; + }).Subscribe(static _ => { }, ex => caught = ex); + + subject.OnNext(1); + release.SetResult(); + await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + + await Assert.That(caught).IsSameReferenceAs(expected); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/FirstMatchFromCandidatesAsyncPathTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/FirstMatchFromCandidatesAsyncPathTests.cs new file mode 100644 index 0000000..a3bf546 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/FirstMatchFromCandidatesAsyncPathTests.cs @@ -0,0 +1,366 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Linq; +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.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"; + + /// Candidate key whose projection is an async (never-sync-completing) subject. + private const string AsyncKey = "async"; + + /// Candidate key whose projection is a synchronously-erroring observable. + private const string SyncErrorKey = "sync-error"; + + /// Candidate key whose projection is a synchronously-completing empty observable. + private const string SyncCompleteKey = "sync-complete"; + + /// Candidate key whose projection emits the match value. + private const string HitKey = "hit"; + + /// 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", HitKey]; + var emissionGate = new Subject(); + var results = new List(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = ((IReadOnlyList)keys) + .FirstMatchFromCandidates( + key => key == HitKey ? emissionGate : Observable.Empty(), + static raw => raw, + static value => value == HitKey, + Fallback) + .Subscribe(results.Add, () => completed.TrySetResult(true)); + + emissionGate.OnNext(HitKey); + emissionGate.OnCompleted(); + + var done = await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(done).IsTrue(); + await Assert.That(results).IsCollectionEqualTo([HitKey]); + } + + /// 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(); + + // Second dispose hits the Interlocked.Exchange null-loser branch in AsyncSink.Dispose + // — the first call swapped in null and disposed the previous subscription, so the + // second call sees null and the `?.Dispose()` no-op fires. + 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", HitKey]; + 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 == HitKey, + Fallback) + .Subscribe(results.Add, () => completed = true); + + await Assert.That(results).IsCollectionEqualTo([HitKey]); + 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 = [SyncErrorKey, HitKey]; + var results = new List(); + var completed = false; + + using var sub = ((IReadOnlyList)keys) + .FirstMatchFromCandidates( + key => key == SyncErrorKey + ? new SyncErroringObservable(new InvalidOperationException(SyncErrorKey)) + : Observable.Return(key), + static raw => raw, + static value => value == HitKey, + Fallback) + .Subscribe(results.Add, () => completed = true); + + await Assert.That(results).IsCollectionEqualTo([HitKey]); + 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 = [SyncCompleteKey, HitKey]; + var results = new List(); + var completed = false; + + using var sub = ((IReadOnlyList)keys) + .FirstMatchFromCandidates( + key => key == SyncCompleteKey + ? new SyncCompletingObservable() + : Observable.Return(key), + static raw => raw, + static value => value == HitKey, + Fallback) + .Subscribe(results.Add, () => completed = true); + + await Assert.That(results).IsCollectionEqualTo([HitKey]); + 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 = [HitKey]; + 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 == HitKey, + Fallback) + .Subscribe(results.Add, () => completed.TrySetResult()); + + subject.OnNext(HitKey); + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + subject.OnNext("ignored-late"); + subject.OnError(new InvalidOperationException("ignored-late")); + subject.OnCompleted(); + + await Assert.That(results).IsCollectionEqualTo([HitKey]); + } + + /// Drives AsyncSink.TryNext through a candidate whose projection + /// synchronously errors — that path enters AsyncSink.OnError while _looping == true + /// (inside TryNext), exercising the if (_looping) return; guard. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAsyncSinkWalkHitsSyncErroringCandidate_ThenLoopingGuardSkipsAhead() + { + // First candidate's projection is async (never completes during Subscribe), forcing + // TrySyncLoop to hand off to AsyncSink. Second candidate's projection synchronously + // errors during AsyncSink.TryNext's loop iteration, hitting AsyncSink.OnError with + // _looping == true. + string[] keys = [AsyncKey, SyncErrorKey, HitKey]; + var asyncSubject = new Subject(); + var results = new List(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = ((IReadOnlyList)keys) + .FirstMatchFromCandidates( + key => key switch + { + AsyncKey => asyncSubject, + SyncErrorKey => new SyncErroringObservable(new InvalidOperationException("sync")), + _ => Observable.Return(key), + }, + static raw => raw, + static value => value == HitKey, + Fallback) + .Subscribe(results.Add, () => completed.TrySetResult()); + + // Complete the async subject — AsyncSink.OnCompleted runs (outside TryNext, so _looping + // is false), which invokes TryNext. The next iteration projects SyncErrorKey whose + // SyncErroringObservable.Subscribe calls observer.OnError synchronously, re-entering + // AsyncSink.OnError while _looping is still true — hitting the looping-guard return. + asyncSubject.OnCompleted(); + + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(results).IsCollectionEqualTo([HitKey]); + } + + /// Same shape as the looping-error case but the intermediate candidate + /// synchronously completes instead of erroring, exercising + /// AsyncSink.OnCompleted's _looping guard. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAsyncSinkWalkHitsSyncCompletingCandidate_ThenLoopingGuardSkipsAhead() + { + string[] keys = [AsyncKey, SyncCompleteKey, HitKey]; + var asyncSubject = new Subject(); + var results = new List(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = ((IReadOnlyList)keys) + .FirstMatchFromCandidates( + key => key switch + { + AsyncKey => asyncSubject, + SyncCompleteKey => new SyncCompletingObservable(), + _ => Observable.Return(key), + }, + static raw => raw, + static value => value == HitKey, + Fallback) + .Subscribe(results.Add, () => completed.TrySetResult()); + + asyncSubject.OnCompleted(); + + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(results).IsCollectionEqualTo([HitKey]); + } + + /// 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.Primitives.Extensions.Tests/Operators/FirstMatchFromCandidatesAsyncTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/FirstMatchFromCandidatesAsyncTests.cs new file mode 100644 index 0000000..68cac2a --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/FirstMatchFromCandidatesAsyncTests.cs @@ -0,0 +1,188 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Linq; +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Tests covering the async-sink path of +/// FirstMatchFromCandidates (when projections do not complete synchronously) +/// and edge cases not exercised by the sync fast-path tests. +public class FirstMatchFromCandidatesAsyncTests +{ + /// Fallback value emitted when no candidate satisfies the predicate. + private const string Fallback = "fallback"; + + /// Sentinel raw value used by tests that drive an async projection to a matching emission. + private const string MatchRaw = "match"; + + /// Verifies that the empty-candidate fast path emits the fallback and completes. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCandidatesEmpty_ThenEmitsFallback() + { + var results = new List(); + var completed = false; + + using var sub = Array.Empty().FirstMatchFromCandidates( + static _ => Observable.Empty(), + static raw => raw, + static _ => true, + Fallback).Subscribe(results.Add, () => completed = true); + + await Assert.That(results).IsCollectionEqualTo([Fallback]); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that when the projection is asynchronous and emits a matching value, + /// the downstream receives that transformed value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAsyncProjectionEmitsMatch_ThenEmitsTransformedValue() + { + using var s = new Subject(); + var results = new List(); + var keys = new[] { "k" }; + + using var sub = keys.FirstMatchFromCandidates( + _ => s, + static raw => raw.ToUpperInvariant(), + static transformed => transformed.StartsWith('M'), + Fallback).Subscribe(results.Add); + + s.OnNext("match"); + + await Assert.That(results).IsCollectionEqualTo(["MATCH"]); + } + + /// Verifies that when every async projection completes without a match the fallback is emitted. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAsyncProjectionsCompleteWithoutMatch_ThenEmitsFallback() + { + var s1 = new Subject(); + var s2 = new Subject(); + var subjects = new[] { s1, s2 }; + var results = new List(); + var completed = false; + var keys = new[] { 0, 1 }; + + using var sub = keys.FirstMatchFromCandidates( + i => subjects[i], + static raw => raw, + static _ => false, + Fallback).Subscribe(results.Add, () => completed = true); + + s1.OnCompleted(); + s2.OnCompleted(); + + await Assert.That(results).IsCollectionEqualTo([Fallback]); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that an async projection's error skips the candidate and the next is tried. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAsyncProjectionErrors_ThenSkipsAndContinues() + { + var s1 = new Subject(); + var s2 = new Subject(); + var subjects = new[] { s1, s2 }; + var results = new List(); + var keys = new[] { 0, 1 }; + + using var sub = keys.FirstMatchFromCandidates( + i => subjects[i], + static raw => raw, + static raw => raw == MatchRaw, + Fallback).Subscribe(results.Add); + + s1.OnError(new InvalidOperationException("ignored")); + s2.OnNext(MatchRaw); + + await Assert.That(results).IsCollectionEqualTo([MatchRaw]); + } + + /// Verifies that when the transform throws on the async path the candidate is skipped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAsyncTransformThrows_ThenValueIsIgnoredAndPipelineContinues() + { + using var s = new Subject(); + var results = new List(); + var keys = new[] { "k" }; + + using var sub = keys.FirstMatchFromCandidates( + _ => s, + static raw => + { + if (raw == "bad") + { + throw new InvalidOperationException("transform failed"); + } + + return raw.ToUpperInvariant(); + }, + static t => t.StartsWith('G'), + Fallback).Subscribe(results.Add); + + s.OnNext("bad"); + s.OnNext("good"); + + await Assert.That(results).IsCollectionEqualTo(["GOOD"]); + } + + /// Verifies that disposing the async sink before the projection emits prevents any + /// downstream notifications. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAsyncSinkDisposedBeforeEmission_ThenNoDownstreamNotifications() + { + using var s = new Subject(); + var results = new List(); + var completed = false; + var keys = new[] { "k" }; + + var sub = keys.FirstMatchFromCandidates( + _ => s, + static raw => raw, + static _ => true, + Fallback).Subscribe(results.Add, () => completed = true); + + sub.Dispose(); + s.OnNext("ignored"); + s.OnCompleted(); + + await Assert.That(results).IsEmpty(); + await Assert.That(completed).IsFalse(); + } + + /// Verifies that a thrown projection on the async path is skipped and the loop continues + /// to the next candidate. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAsyncProjectionFactoryThrows_ThenSkipsAndContinues() + { + using var asyncFirst = new Subject(); + var results = new List(); + var keys = new[] { "async-open", "throw-key", "match" }; + + using var sub = keys.FirstMatchFromCandidates( + key => key switch + { + "async-open" => asyncFirst, + "throw-key" => throw new InvalidOperationException("project failed"), + _ => Observable.Return(key), + }, + static raw => raw.ToUpperInvariant(), + static t => t == "MATCH", + Fallback).Subscribe(results.Add); + + // Trigger async sink: complete the first projection so AsyncSink.TryNext walks the rest. + asyncFirst.OnCompleted(); + + await Assert.That(results).IsCollectionEqualTo(["MATCH"]); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ForEachObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ForEachObservableTests.cs new file mode 100644 index 0000000..cbb5037 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ForEachObservableTests.cs @@ -0,0 +1,90 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Extensions.Operators; + +namespace ReactiveUI.Primitives.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(Sequencer.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.Primitives.Extensions.Tests/Operators/HeartbeatObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/HeartbeatObservableTests.cs new file mode 100644 index 0000000..4b31cd1 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/HeartbeatObservableTests.cs @@ -0,0 +1,148 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Edge-case coverage for Heartbeat backed by +/// HeartbeatObservable<T> — heartbeat-on-quiet, error/completion +/// forwarding, and post-terminal timer suppression. +public class HeartbeatObservableTests +{ + /// Heartbeat period for the scheduler-driven tests. + private const int HeartbeatTicks = 100; + + /// Tick advance large enough to fire several heartbeats. + private const int LargeAdvanceTicks = 500; + + /// Message attached to synthetic source errors. + private const string SourceErrorMessage = "source error"; + + /// Verifies that the heartbeat scheduler injects heartbeats when the source is quiet. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenHeartbeatSourceQuiet_ThenEmitsHeartbeats() + { + const int ModestAdvanceTicks = 350; + var scheduler = new VirtualClock(); + var subject = new Subject(); + var heartbeats = 0; + + using var sub = subject.Heartbeat(TimeSpan.FromTicks(HeartbeatTicks), scheduler) + .Subscribe(hb => heartbeats += hb.IsHeartbeat ? 1 : 0); + + scheduler.AdvanceBy(ModestAdvanceTicks); + + await Assert.That(heartbeats).IsGreaterThanOrEqualTo(1); + } + + /// Verifies that Heartbeat forwards source errors and stops the timer. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenHeartbeatSourceErrors_ThenForwardsErrorAndStopsTimer() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + Exception? caught = null; + var postErrorHeartbeats = 0; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.Heartbeat(TimeSpan.FromTicks(HeartbeatTicks), scheduler) + .Subscribe( + hb => postErrorHeartbeats += hb.IsHeartbeat && caught is not null ? 1 : 0, + ex => caught = ex); + + subject.OnError(expected); + scheduler.AdvanceBy(LargeAdvanceTicks); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(postErrorHeartbeats).IsEqualTo(0); + } + + /// Verifies that Heartbeat forwards completion and stops the timer. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenHeartbeatSourceCompletes_ThenForwardsCompletionAndStopsTimer() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var completed = false; + var postCompletionHeartbeats = 0; + + using var sub = subject.Heartbeat(TimeSpan.FromTicks(HeartbeatTicks), scheduler) + .Subscribe( + hb => postCompletionHeartbeats += hb.IsHeartbeat && completed ? 1 : 0, + () => completed = true); + + subject.OnCompleted(); + scheduler.AdvanceBy(LargeAdvanceTicks); + + await Assert.That(completed).IsTrue(); + await Assert.That(postCompletionHeartbeats).IsEqualTo(0); + } + + /// Verifies that a source emission wraps the value as a non-heartbeat update. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenHeartbeatSourceEmits_ThenForwardsValueUpdate() + { + const int Value = 42; + var scheduler = new VirtualClock(); + var subject = new Subject(); + var updates = new List(); + + using var sub = subject.Heartbeat(TimeSpan.FromTicks(HeartbeatTicks), scheduler) + .Subscribe(hb => + { + if (hb.IsHeartbeat) + { + return; + } + + updates.Add(hb.Update); + }); + + subject.OnNext(Value); + + 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 VirtualClock(); + 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.Primitives.Extensions.Tests/Operators/LogErrorsObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/LogErrorsObservableTests.cs new file mode 100644 index 0000000..94d8cf3 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/LogErrorsObservableTests.cs @@ -0,0 +1,63 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; +using ReactiveUI.Primitives.Extensions.Operators; + +namespace ReactiveUI.Primitives.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.Primitives.Extensions.Tests/Operators/MinMaxObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/MinMaxObservableTests.cs new file mode 100644 index 0000000..86665f1 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/MinMaxObservableTests.cs @@ -0,0 +1,179 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; +using ReactiveUI.Primitives.Extensions.Operators; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Edge-case coverage for the GetMin / GetMax operators +/// backed by MinMaxObservable<T> — partial-source suppression, +/// max/min selection over multiple updates, and source-error propagation. +public class MinMaxObservableTests +{ + /// Synthetic error message attached to source errors. + private const string SourceErrorMessage = "source error"; + + /// Sentinel value for the first source. + private const int LowValue = 1; + + /// Sentinel value for the second source (initial). + private const int MidValue = 5; + + /// Sentinel value used as an update to the second source. + private const int HighValue = 10; + + /// Verifies that GetMax emits the largest of the latest values from every source. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenGetMaxTwoSources_ThenEmitsMaxAcrossUpdates() + { + var a = new Subject(); + var b = new Subject(); + var results = new List(); + + using var sub = a.GetMax(b).Subscribe(results.Add); + + a.OnNext(LowValue); + b.OnNext(MidValue); + b.OnNext(HighValue); + a.OnNext(MidValue); + + await Assert.That(results).IsCollectionEqualTo([MidValue, HighValue, HighValue]); + } + + /// Verifies that GetMin emits the smallest of the latest values from every source. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenGetMinTwoSources_ThenEmitsMinAcrossUpdates() + { + var a = new Subject(); + var b = new Subject(); + var results = new List(); + + using var sub = a.GetMin(b).Subscribe(results.Add); + + a.OnNext(HighValue); + b.OnNext(MidValue); + a.OnNext(LowValue); + + await Assert.That(results).IsCollectionEqualTo([MidValue, LowValue]); + } + + /// Verifies that partial sources do not emit until every source has a value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenGetMaxPartialSources_ThenSuppressesEmission() + { + var a = new Subject(); + var b = new Subject(); + var results = new List(); + + using var sub = a.GetMax(b).Subscribe(results.Add); + + a.OnNext(LowValue); + + await Assert.That(results).IsEmpty(); + } + + /// Verifies that a source error propagates downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenGetMaxSourceErrors_ThenForwardsError() + { + var a = new Subject(); + var b = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = a.GetMax(b).Subscribe(static _ => { }, ex => caught = ex); + + a.OnError(expected); + + 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 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 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.Primitives.Extensions.Tests/Operators/NotObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/NotObservableTests.cs new file mode 100644 index 0000000..fde8686 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/NotObservableTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; +using ReactiveUI.Primitives.Extensions.Operators; + +namespace ReactiveUI.Primitives.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.Primitives.Extensions.Tests/Operators/ObserveOnIfObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ObserveOnIfObservableTests.cs new file mode 100644 index 0000000..e17ae9b --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ObserveOnIfObservableTests.cs @@ -0,0 +1,234 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Edge-case coverage for the reactive-condition ObserveOnIf overload +/// backed by ObserveOnIfObservable<T> — condition switching, error forwarding, +/// and completion forwarding. +public class ObserveOnIfObservableTests +{ + /// Synthetic error message attached to source errors. + private const string SourceErrorMessage = "source error"; + + /// Verifies that values dispatch on the false-scheduler before any condition arrives. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenObserveOnIfNoCondition_ThenUsesFalseScheduler() + { + const int Value = 11; + var source = new Subject(); + var condition = new Subject(); + var trueScheduler = new RecordingScheduler(); + var falseScheduler = new RecordingScheduler(); + var emitted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = source.ObserveOnIf(condition, trueScheduler, falseScheduler) + .Subscribe(v => emitted.TrySetResult(v)); + + source.OnNext(Value); + + var v2 = await emitted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(v2).IsEqualTo(Value); + await Assert.That(falseScheduler.ScheduleCount).IsGreaterThanOrEqualTo(1); + await Assert.That(trueScheduler.ScheduleCount).IsEqualTo(0); + } + + /// Verifies that emitting after the condition becomes true dispatches on the true-scheduler. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenObserveOnIfConditionTrue_ThenUsesTrueScheduler() + { + const int Value = 22; + var source = new Subject(); + var condition = new Subject(); + var trueScheduler = new RecordingScheduler(); + var falseScheduler = new RecordingScheduler(); + var emitted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = source.ObserveOnIf(condition, trueScheduler, falseScheduler) + .Subscribe(v => emitted.TrySetResult(v)); + + condition.OnNext(true); + source.OnNext(Value); + + var v2 = await emitted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(v2).IsEqualTo(Value); + await Assert.That(trueScheduler.ScheduleCount).IsGreaterThanOrEqualTo(1); + } + + /// Verifies that ObserveOnIf forwards source errors without scheduler dispatch. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenObserveOnIfSourceErrors_ThenForwardsError() + { + var source = new Subject(); + var condition = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = source.ObserveOnIf(condition, TaskPoolSequencer.Default, Sequencer.Immediate) + .Subscribe(static _ => { }, ex => caught = ex); + + source.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that ObserveOnIf forwards source completion. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenObserveOnIfSourceCompletes_ThenForwardsCompletion() + { + var source = new Subject(); + var condition = new Subject(); + var completed = false; + + using var sub = source.ObserveOnIf(condition, TaskPoolSequencer.Default, Sequencer.Immediate) + .Subscribe(static _ => { }, () => completed = true); + + source.OnCompleted(); + + await Assert.That(completed).IsTrue(); + } + + /// Verifies that the single-scheduler overload defaults the false branch to + /// by emitting synchronously when the condition is false. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenObserveOnIfSingleSchedulerConditionFalse_ThenImmediate() + { + const int Value = 33; + var source = new Subject(); + var condition = new Subject(); + var trueScheduler = new RecordingScheduler(); + var results = new List(); + + using var sub = source.ObserveOnIf(condition, trueScheduler) + .Subscribe(results.Add); + + condition.OnNext(false); + source.OnNext(Value); + + await Assert.That(results).IsCollectionEqualTo([Value]); + 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 = Sequencer.Immediate; + var falseScheduler = Sequencer.Immediate; + 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(); + } + + /// Exercises the _done guard inside the scheduled callback — + /// when the source completes between OnNext's schedule call and the scheduler firing + /// the queued callback, the callback observes _done == true and returns without + /// forwarding to downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScheduledCallbackFiresAfterSourceCompleted_ThenDroppedByDoneGuard() + { + var source = new SyncDirectSource(); + var condition = new Subject(); + var scheduler = new VirtualClock(); + var values = new List(); + var completedCount = 0; + + using var sub = source.ObserveOnIf(condition, scheduler, scheduler) + .Subscribe(values.Add, () => completedCount++); + + // First emission queues the forward-to-downstream callback on the VirtualClock. + source.Observer.OnNext(1); + + // Source completes synchronously, flipping _done = true before the queued callback runs. + source.Observer.OnCompleted(); + + // Advance the scheduler so the queued callback fires; it observes _done == true and + // returns at the in-callback guard rather than calling downstream.OnNext. + scheduler.AdvanceBy(1); + + 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); + } + + /// Sequencer that delegates to the default thread-pool sequencer but records each scheduled work item. + private sealed class RecordingScheduler : ISequencer + { + /// Backing scheduler used to actually dispatch work. + private readonly TaskPoolSequencer _inner = TaskPoolSequencer.Default; + + /// Gets the number of recorded schedule calls. + public int ScheduleCount { get; private set; } + + /// + public DateTimeOffset Now => _inner.Now; + + /// + public long Timestamp => _inner.Timestamp; + + /// + public void Schedule(IWorkItem item) + { + ScheduleCount++; + _inner.Schedule(item); + } + + /// + public void Schedule(IWorkItem item, long dueTimestamp) + { + ScheduleCount++; + _inner.Schedule(item, dueTimestamp); + } + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ObserveOnObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ObserveOnObservableTests.cs new file mode 100644 index 0000000..1bc0b53 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ObserveOnObservableTests.cs @@ -0,0 +1,172 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Disposables; +using System.Reactive.Subjects; +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Coverage for ObserveOnObservable (reached via ObserveOnSafe) — the +/// immediate-scheduler passthrough, the queue-and-drain marshaller's value / error / completion +/// forwarding, dispose teardown, and the attach-after-terminated branch of the shared drain state. +public class ObserveOnObservableTests +{ + /// Synthetic error message attached to source errors. + private const string SourceErrorMessage = "source error"; + + /// Second sentinel value (kept as a constant to satisfy the no-magic-number rule). + private const int SecondValue = 2; + + /// Verifies the immediate scheduler is special-cased to forward straight through the source + /// subscription without the queue-and-drain machinery. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenImmediateScheduler_ThenForwardsDirectly() + { + var source = new Subject(); + var values = new List(); + var completed = false; + + using var sub = source.ObserveOnSafe(Sequencer.Immediate) + .Subscribe(values.Add, () => completed = true); + + source.OnNext(1); + source.OnNext(SecondValue); + source.OnCompleted(); + + await Assert.That(values).IsCollectionEqualTo([1, SecondValue]); + await Assert.That(completed).IsTrue(); + } + + /// Verifies queued values are drained downstream in FIFO order on the scheduler thread. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenValuesMarshalled_ThenForwardedInOrderOnDrain() + { + var scheduler = new VirtualClock(); + var source = new Subject(); + var values = new List(); + + using var sub = source.ObserveOnSafe(scheduler).Subscribe(values.Add); + + source.OnNext(1); + source.OnNext(SecondValue); + + // Nothing forwarded until the scheduled drain pass runs. + await Assert.That(values).IsEmpty(); + + scheduler.AdvanceBy(1); + + await Assert.That(values).IsCollectionEqualTo([1, SecondValue]); + } + + /// Verifies a source error is forwarded through the scheduler marshaller. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceErrors_ThenForwardsError() + { + var scheduler = new VirtualClock(); + var source = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = source.ObserveOnSafe(scheduler).Subscribe(static _ => { }, ex => caught = ex); + + source.OnError(expected); + scheduler.AdvanceBy(1); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies source completion is forwarded through the scheduler marshaller. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceCompletes_ThenForwardsCompletion() + { + var scheduler = new VirtualClock(); + var source = new Subject(); + var completed = false; + + using var sub = source.ObserveOnSafe(scheduler).Subscribe(static _ => { }, () => completed = true); + + source.OnCompleted(); + scheduler.AdvanceBy(1); + + await Assert.That(completed).IsTrue(); + } + + /// Verifies disposing tears down the upstream subscription and stops forwarding queued values. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDisposedBeforeDrain_ThenTearsDownAndDropsQueued() + { + var scheduler = new VirtualClock(); + var source = new Subject(); + var values = new List(); + + var sub = source.ObserveOnSafe(scheduler).Subscribe(values.Add); + + source.OnNext(1); + await Assert.That(source.HasObservers).IsTrue(); + + sub.Dispose(); + await Assert.That(source.HasObservers).IsFalse(); + + scheduler.AdvanceBy(1); + await Assert.That(values).IsEmpty(); + } + + /// Verifies the upstream subscription is disposed when the source terminates synchronously during + /// subscribe — the drain runs inline (terminating the sink) before AttachSourceSubscription records + /// the handle, so the late attach disposes it instead. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceTerminatesDuringSubscribe_ThenLateAttachDisposesSubscription() + { + var expected = new InvalidOperationException(SourceErrorMessage); + var source = new SyncErroringObservable(expected); + Exception? caught = null; + + using var sub = source.ObserveOnSafe(new InlineScheduler()) + .Subscribe(static _ => { }, ex => caught = ex); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(source.Subscription.IsDisposed).IsTrue(); + } + + /// Observable that synchronously errors during Subscribe and exposes the subscription + /// handle it returned so tests can assert it was disposed. + /// The element type. + private sealed class SyncErroringObservable(Exception error) : IObservable + { + /// Gets the subscription handle returned from the most recent subscribe. + public BooleanDisposable Subscription { get; } = new(); + + /// + public IDisposable Subscribe(IObserver observer) + { + observer.OnError(error); + return Subscription; + } + } + + /// Scheduler that runs scheduled work synchronously on the calling thread, so a drain pass + /// executes inline during the schedule call. Distinct instance from + /// so the operator's immediate-scheduler passthrough does not apply. + private sealed class InlineScheduler : ISequencer + { + /// + public DateTimeOffset Now => DateTimeOffset.MinValue; + + /// + public long Timestamp => Now.UtcTicks; + + /// + public void Schedule(IWorkItem item) => item.Execute(); + + /// + public void Schedule(IWorkItem item, long dueTimestamp) => item.Execute(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/OperatorAfterTerminalGuardTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/OperatorAfterTerminalGuardTests.cs new file mode 100644 index 0000000..5a5b8da --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/OperatorAfterTerminalGuardTests.cs @@ -0,0 +1,541 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.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 — and that a second dispose hits the + /// Interlocked.Exchange != 0 idempotency guard in . + /// 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(); + + // First dispose latches _disposed in the retry sink. + sub.Dispose(); + + // Second dispose exercises the Interlocked.Exchange idempotency guard. + 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(); + } + + /// Exercises RetryWithDelay.SubscribeToSource's _disposed guard — + /// when the source errors and schedules a delayed re-subscribe, then the subscription is + /// disposed before the delay elapses, the scheduled callback invokes SubscribeToSource + /// which sees _disposed == true and returns at the guard rather than re-subscribing. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRetryWithDelayDisposedDuringDelay_ThenSubscribeToSourceGuardSkipsRetry() + { + const int LongDelayMs = 250; + var subscribeCount = 0; + IObservable source = Observable.Create(o => + { + subscribeCount++; + o.OnError(new InvalidOperationException("retry-after-dispose")); + return System.Reactive.Disposables.Disposable.Empty; + }); + + var sub = source.RetryForeverWithDelay(TimeSpan.FromMilliseconds(LongDelayMs)) + .Subscribe(static _ => { }); + + // First subscribe ran; source errored synchronously and a retry has been scheduled. + sub.Dispose(); + + // Wait past the delay window so the scheduled callback fires while _disposed = true, + // hitting the SubscribeToSource _disposed guard rather than re-subscribing. + await Task.Delay(LongDelayMs + LongDelayMs); + + await Assert.That(subscribeCount).IsEqualTo(1); + } + + /// Exercises RetryWithBackoff.SubscribeToSource's _disposed guard — + /// same shape as the RetryWithDelay variant. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRetryWithBackoffDisposedDuringDelay_ThenSubscribeToSourceGuardSkipsRetry() + { + const int LongDelayMs = 250; + var subscribeCount = 0; + IObservable source = Observable.Create(o => + { + subscribeCount++; + o.OnError(new InvalidOperationException("retry-after-dispose")); + return System.Reactive.Disposables.Disposable.Empty; + }); + + var sub = source.OnErrorRetry( + (Exception _) => { }, + 10, + TimeSpan.FromMilliseconds(LongDelayMs), + TaskPoolSequencer.Default) + .Subscribe(static _ => { }); + + sub.Dispose(); + + await Task.Delay(LongDelayMs + LongDelayMs); + + await Assert.That(subscribeCount).IsEqualTo(1); + } + + /// 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 VirtualClock(); + 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 DetectStale's post-completion OnNext guard — values + /// arriving after the upstream completed are dropped at the _state.Done check. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDetectStaleEventsAfterCompleted_ThenDropped() + { + var scheduler = new VirtualClock(); + var source = new SyncDirectSource(); + var values = new List>(); + var completedCount = 0; + + using var sub = source.DetectStale(TimeSpan.FromTicks(TickWindow), scheduler) + .Subscribe(values.Add, () => completedCount++); + + source.Observer.OnCompleted(); + source.Observer.OnNext(1); + scheduler.AdvanceBy(TickWindow * SettleMultiplier); + + await Assert.That(completedCount).IsEqualTo(1); + await Assert.That(values).IsEmpty(); + } + + /// Verifies DropIfBusy's post-completion OnNext guard. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDropIfBusyEventsAfterCompleted_ThenDropped() + { + var source = new SyncDirectSource(); + var values = new List(); + var completedCount = 0; + + using var sub = source.DropIfBusy(static _ => default) + .Subscribe(values.Add, () => completedCount++); + + source.Observer.OnCompleted(); + source.Observer.OnNext(1); + source.Observer.OnError(new InvalidOperationException("late")); + + await Assert.That(completedCount).IsEqualTo(1); + await Assert.That(values).IsEmpty(); + } + + /// Exercises WhileObservable.Iterate's _disposed guard — when the + /// downstream consumer's OnNext callback disposes the subscription captured via + /// a single-assignment slot, the post-action call back to Iterate sees + /// _disposed == 1 and returns at the guard rather than re-entering RunActionAndContinue. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhileDownstreamDisposesInsideOnNext_ThenIterateGuardSkipsNextPredicate() + { + // The scheduler indirection lets us defer the first iteration to after Subscribe has + // returned (so the SingleAssignmentDisposable can capture the subscription), then run + // the inner iterations synchronously enough that the OnNext-side dispose hits before + // the second Iterate evaluates the predicate. + var scheduler = new VirtualClock(); + var actionCalls = 0; + var sub = new SingleAssignmentDisposable(); + + sub.Disposable = ReactiveExtensions.While( + () => true, + () => actionCalls++, + scheduler) + .Subscribe(_ => sub.Dispose()); + + scheduler.AdvanceBy(1); + + await Assert.That(actionCalls).IsEqualTo(1); + } + + /// Exercises ThrottleDistinct's scheduled-emit done guard — when the source + /// completes between a value being received and the throttle window elapsing, the + /// scheduled Emit callback sees _state.Done == true and returns without + /// forwarding the buffered value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleDistinctSourceCompletesBeforeEmitWindow_ThenScheduledEmitDropped() + { + var scheduler = new VirtualClock(); + var source = new SyncDirectSource(); + var values = new List(); + var completedCount = 0; + + using var sub = source.ThrottleDistinct(TimeSpan.FromTicks(TickWindow), scheduler) + .Subscribe(values.Add, () => completedCount++); + + source.Observer.OnNext(1); + source.Observer.OnCompleted(); + scheduler.AdvanceBy(TickWindow * SettleMultiplier); + + await Assert.That(completedCount).IsEqualTo(1); + await Assert.That(values).IsEmpty(); + } + + /// Exercises the SampleLatest trigger-error post-terminal guard — when the + /// source has already errored (setting _done = true), a subsequent error on the + /// trigger observer hits the if (_done) return; guard inside the trigger's + /// OnError delegate. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSampleLatestTriggerErrorsAfterSourceErrored_ThenDroppedByDoneGuard() + { + var source = new Subject(); + var trigger = new Subject(); + Exception? caught = null; + var sourceError = new InvalidOperationException("source"); + + using var sub = source.SampleLatest(trigger) + .Subscribe(static _ => { }, ex => caught = ex); + + source.OnError(sourceError); + trigger.OnError(new InvalidOperationException("trigger")); + + await Assert.That(caught).IsSameReferenceAs(sourceError); + } + + /// Verifies SampleLatest's post-completion Sample guard. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSampleLatestSampledAfterCompleted_ThenNoOp() + { + var source = new SyncDirectSource(); + var sampler = new Subject(); + var values = new List(); + var completedCount = 0; + + using var sub = source.SampleLatest(sampler) + .Subscribe(values.Add, () => completedCount++); + + source.Observer.OnNext(1); + source.Observer.OnCompleted(); + sampler.OnNext(new object()); + + await Assert.That(completedCount).IsEqualTo(1); + } + + /// Verifies Heartbeat's post-completion OnNext guard. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenHeartbeatEventsAfterCompleted_ThenDropped() + { + var scheduler = new VirtualClock(); + var source = new SyncDirectSource(); + var completedCount = 0; + + using var sub = source.Heartbeat(TimeSpan.FromTicks(TickWindow), scheduler) + .Subscribe(static _ => { }, () => completedCount++); + + source.Observer.OnCompleted(); + source.Observer.OnNext(1); + scheduler.AdvanceBy(TickWindow * SettleMultiplier); + + await Assert.That(completedCount).IsEqualTo(1); + } + + /// Exercises Heartbeat's ScheduleHeartbeats _done guard — + /// when the source completes synchronously during source.Subscribe(sink), the sink + /// is marked done before sink.Initialize() runs, so the post-Initialize call to + /// ScheduleHeartbeats returns at the _done check without arming the timer. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenHeartbeatSourceCompletesDuringSubscribe_ThenInitializeShortCircuits() + { + var scheduler = new VirtualClock(); + var completedCount = 0; + + using var sub = Observable.Empty().Heartbeat(TimeSpan.FromTicks(TickWindow), scheduler) + .Subscribe(static _ => { }, () => completedCount++); + + scheduler.AdvanceBy(TickWindow * SettleMultiplier); + + await Assert.That(completedCount).IsEqualTo(1); + } + + /// Verifies DebounceUntil's post-completion sink guard — values arriving + /// after the upstream has already completed are dropped at the _state.Done check + /// inside OnNext. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDebounceUntilEventsAfterCompleted_ThenDropped() + { + var scheduler = new VirtualClock(); + var source = new SyncDirectSource(); + var values = new List(); + var completedCount = 0; + + using var sub = source.DebounceUntil(TimeSpan.FromTicks(TickWindow), static _ => true, scheduler) + .Subscribe(values.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); + 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 VirtualClock(); + 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 = Sequencer.Immediate; + var falseScheduler = Sequencer.Immediate; + 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 VirtualClock(); + 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 default; + }); + + 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 _ => default); + second.OnCompleted(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/PartitionObservableTests.MultiSubscriber.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/PartitionObservableTests.MultiSubscriber.cs new file mode 100644 index 0000000..27c3d1d --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/PartitionObservableTests.MultiSubscriber.cs @@ -0,0 +1,97 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.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.Primitives.Extensions.Tests/Operators/PartitionObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/PartitionObservableTests.cs new file mode 100644 index 0000000..7ca8019 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/PartitionObservableTests.cs @@ -0,0 +1,201 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.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 partial class PartitionObservableTests +{ + /// Synthetic error message attached to source errors. + private const string SourceErrorMessage = "source error"; + + /// Even-modulus divisor. + private const int Two = 2; + + /// First odd value. + private const int One = 1; + + /// Second odd value. + private const int Three = 3; + + /// Second even value. + private const int Four = 4; + + /// Verifies that elements route to the correct side based on the predicate. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenPartitionWithBothSidesSubscribed_ThenRoutesByPredicate() + { + var subject = new Subject(); + var (evens, odds) = subject.Partition(static x => x % Two == 0); + var evenResults = new List(); + var oddResults = new List(); + + using var evenSub = evens.Subscribe(evenResults.Add); + using var oddSub = odds.Subscribe(oddResults.Add); + + subject.OnNext(One); + subject.OnNext(Two); + subject.OnNext(Three); + subject.OnNext(Four); + + await Assert.That(evenResults).IsCollectionEqualTo([Two, Four]); + await Assert.That(oddResults).IsCollectionEqualTo([One, Three]); + } + + /// Verifies that a value with no observer on its side is silently dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenPartitionOnlyOneSideSubscribed_ThenOtherSideValuesDropped() + { + var subject = new Subject(); + var (evens, _) = subject.Partition(static x => x % Two == 0); + var evenResults = new List(); + + using var evenSub = evens.Subscribe(evenResults.Add); + + subject.OnNext(One); + subject.OnNext(Two); + + await Assert.That(evenResults).IsCollectionEqualTo([Two]); + } + + /// Verifies that errors are broadcast to both sides. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenPartitionSourceErrors_ThenBroadcastsToBothSides() + { + var subject = new Subject(); + var (evens, odds) = subject.Partition(static x => x % Two == 0); + Exception? evenError = null; + Exception? oddError = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var evenSub = evens.Subscribe(static _ => { }, ex => evenError = ex); + using var oddSub = odds.Subscribe(static _ => { }, ex => oddError = ex); + + subject.OnError(expected); + + await Assert.That(evenError).IsSameReferenceAs(expected); + await Assert.That(oddError).IsSameReferenceAs(expected); + } + + /// Verifies that completion is broadcast to both sides. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenPartitionSourceCompletes_ThenBroadcastsToBothSides() + { + var subject = new Subject(); + var (evens, odds) = subject.Partition(static x => x % Two == 0); + var evenCompleted = false; + var oddCompleted = false; + + using var evenSub = evens.Subscribe(static _ => { }, () => evenCompleted = true); + using var oddSub = odds.Subscribe(static _ => { }, () => oddCompleted = true); + + subject.OnCompleted(); + + await Assert.That(evenCompleted).IsTrue(); + await Assert.That(oddCompleted).IsTrue(); + } + + /// Verifies that disposing one side stops it from receiving further emissions + /// while the other side keeps receiving. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenPartitionOneSideDisposed_ThenOnlyOtherSideReceives() + { + var subject = new Subject(); + var (evens, odds) = subject.Partition(static x => x % Two == 0); + var evenResults = new List(); + var oddResults = new List(); + + var evenSub = evens.Subscribe(evenResults.Add); + using var oddSub = odds.Subscribe(oddResults.Add); + + subject.OnNext(Two); + evenSub.Dispose(); + subject.OnNext(Four); + subject.OnNext(Three); + + await Assert.That(evenResults).IsCollectionEqualTo([Two]); + await Assert.That(oddResults).IsCollectionEqualTo([Three]); + } + + /// Verifies that the partition can be resubscribed after all sides drop — + /// the source subscription is re-established. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenPartitionResubscribedAfterAllSidesDropped_ThenSourceRebound() + { + var subject = new Subject(); + var (evens, _) = subject.Partition(static x => x % Two == 0); + + var firstSub = evens.Subscribe(static _ => { }); + firstSub.Dispose(); + + var secondResults = new List(); + using var secondSub = evens.Subscribe(secondResults.Add); + + subject.OnNext(Two); + + 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.Primitives.Extensions.Tests/Operators/PropertyChangedObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/PropertyChangedObservableTests.cs new file mode 100644 index 0000000..79fbd21 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/PropertyChangedObservableTests.cs @@ -0,0 +1,209 @@ +// 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.Primitives.Extensions.Tests.Operators; + +/// Edge-case coverage for ToPropertyObservable backed by +/// PropertyChangedObservable<T, TProperty> — initial-value emission, +/// matching-name forwarding, unmatched-name filtering, getter-throws forwarding, +/// and dispose detaches the handler. +public class PropertyChangedObservableTests +{ + /// Initial property value. + private const int InitialValue = 1; + + /// Updated property value. + private const int UpdatedValue = 42; + + /// Synthetic error message attached to getter failures. + private const string GetterFailedMessage = "getter failed"; + + /// Verifies that subscribing emits the current property value immediately. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscribePropertyObservable_ThenEmitsCurrentValue() + { + var owner = new ObservableOwner { Value = InitialValue }; + var results = new List(); + + using var sub = owner.ToPropertyObservable(x => x.Value) + .Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo([InitialValue]); + } + + /// Verifies that matching-name property changes are forwarded. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenMatchingPropertyChanges_ThenForwardsValue() + { + var owner = new ObservableOwner { Value = InitialValue }; + var results = new List(); + + using var sub = owner.ToPropertyObservable(x => x.Value) + .Subscribe(results.Add); + + owner.Value = UpdatedValue; + + await Assert.That(results).IsCollectionEqualTo([InitialValue, UpdatedValue]); + } + + /// Verifies that non-matching property changes are ignored. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenUnrelatedPropertyChanges_ThenIgnored() + { + var owner = new ObservableOwner { Value = InitialValue }; + var results = new List(); + + using var sub = owner.ToPropertyObservable(x => x.Value) + .Subscribe(results.Add); + + owner.Other = "anything"; + + await Assert.That(results).IsCollectionEqualTo([InitialValue]); + } + + /// Verifies that disposing detaches the handler and stops forwarding. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscriptionDisposed_ThenNoFurtherEmissions() + { + var owner = new ObservableOwner { Value = InitialValue }; + var results = new List(); + + var sub = owner.ToPropertyObservable(x => x.Value) + .Subscribe(results.Add); + sub.Dispose(); + + owner.Value = UpdatedValue; + + await Assert.That(results).IsCollectionEqualTo([InitialValue]); + } + + /// Verifies that an exception thrown by the getter on a property-changed callback + /// is forwarded to OnError. The initial subscribe read succeeds; the second read (triggered + /// by Raise) throws. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenGetterThrowsOnChange_ThenForwardsError() + { + var owner = new LatchingThrowingOwner(); + Exception? caught = null; + + using var sub = owner.ToPropertyObservable(x => x.Latched) + .Subscribe(static _ => { }, ex => caught = ex); + + owner.ArmAndRaise(); + + await Assert.That(caught).IsTypeOf(); + } + + /// Exercises the OnPropertyChanged _disposed guard — fires a + /// PropertyChanged event for the subscribed property after the subscription has been + /// disposed but using an owner whose remove-handler is a no-op so the event delivery + /// reaches the still-bound handler, which then sees _disposed != 0 and returns. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenPropertyEventFiresAfterDispose_ThenHandlerGuardSkipsForward() + { + var owner = new RetainingObservableOwner(); + var results = new List(); + + var sub = owner.ToPropertyObservable(x => x.Value).Subscribe(results.Add); + + sub.Dispose(); + + // Even after Dispose, the retaining owner still references the handler — invoking the + // event delivers to it, but the handler observes _disposed != 0 and returns early. + owner.Raise(); + + await Assert.That(results).IsCollectionEqualTo([0]); + } + + /// INPC owner that retains every handler ever attached and exposes a manual + /// Raise so a test can fire the PropertyChanged event after the subscription that + /// added the handler has already been disposed. + private sealed class RetainingObservableOwner : INotifyPropertyChanged + { + /// The retained handler list. + private PropertyChangedEventHandler? _retained; + + /// + public event PropertyChangedEventHandler? PropertyChanged + { + add => _retained += value; + remove + { + // Intentionally a no-op so disposed subscriptions stay reachable for Raise(). + } + } + + /// Gets the observed property — never mutated. The body reads this.GetHashCode + /// to keep the getter instance-bound so the ToPropertyObservable expression tree compiler + /// resolves it against this instance. + public int Value => GetHashCode() & 0; + + /// Invokes the retained handler with a PropertyChanged event for . + public void Raise() => _retained?.Invoke(this, new PropertyChangedEventArgs(nameof(Value))); + } + + /// Test owner that fires on property writes. + private sealed class ObservableOwner : INotifyPropertyChanged + { + /// Backing field for . + private int _value; + + /// Backing field for . + private string _other = string.Empty; + + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// Gets or sets the value being observed by the test. + public int Value + { + get => _value; + set + { + _value = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value))); + } + } + + /// Gets or sets the unrelated property used to validate name-filtering. + public string Other + { + get => _other; + set + { + _other = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Other))); + } + } + } + + /// Test owner whose getter succeeds on the first read but throws on subsequent reads. + private sealed class LatchingThrowingOwner : INotifyPropertyChanged + { + /// Failure message used by the latched getter. + private readonly string _message = GetterFailedMessage; + + /// Set after latches the getter into throwing mode. + private bool _armed; + + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// Gets a property that succeeds while disarmed and throws once armed. + public int Latched => _armed ? throw new InvalidOperationException(_message) : 0; + + /// Arms the throwing behaviour and raises a change notification. + public void ArmAndRaise() + { + _armed = true; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Latched))); + } + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/RetryAndThrottleAndFactoryOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/RetryAndThrottleAndFactoryOperatorTests.cs new file mode 100644 index 0000000..6178734 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/RetryAndThrottleAndFactoryOperatorTests.cs @@ -0,0 +1,325 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Linq; +using System.Reactive.Subjects; +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.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 VirtualClock(); + 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 VirtualClock(); + 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 VirtualClock(); + 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 VirtualClock(); + 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 RxVoid-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(RxVoid.Default).SubscribeGetValue(); + await Assert.That(followUp).IsEqualTo(RxVoid.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.Primitives.Extensions.Tests/Operators/RetryForeverObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/RetryForeverObservableTests.cs new file mode 100644 index 0000000..2f5cf4a --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/RetryForeverObservableTests.cs @@ -0,0 +1,103 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Linq; +using System.Reactive.Subjects; +using ReactiveUI.Primitives.Extensions.Operators; + +namespace ReactiveUI.Primitives.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 completion after disposal is ignored. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRetryForeverDisposedBeforeCompletion_ThenCompletionDropped() + { + var source = new SyncDirectSource(); + var completedCount = 0; + + var sub = source.OnErrorRetry().Subscribe(static _ => { }, () => completedCount++); + + sub.Dispose(); + source.Observer.OnCompleted(); + + await Assert.That(completedCount).IsEqualTo(0); + } + + /// 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.Primitives.Extensions.Tests/Operators/RunAllObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/RunAllObservableTests.cs new file mode 100644 index 0000000..3f5c125 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/RunAllObservableTests.cs @@ -0,0 +1,211 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Linq; +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Edge-case coverage for RunAll backed by RunAllObservable — +/// empty-list short-circuit, sequential walk through synchronous and asynchronous +/// sources, error propagation, and disposal mid-walk. +public class RunAllObservableTests +{ + /// Synthetic error message attached to source errors. + private const string SourceErrorMessage = "source error"; + + /// Verifies that an empty list emits and completes immediately. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRunAllEmptyList_ThenEmitsAndCompletes() + { + var emitted = 0; + var completed = false; + + using var sub = Array.Empty>().RunAll() + .Subscribe(_ => emitted++, () => completed = true); + + await Assert.That(emitted).IsEqualTo(1); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that synchronous sources iterate without stack growth and complete. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRunAllSyncSources_ThenWalksAllAndCompletes() + { + const int SourceCount = 5; + var runOrder = new List(); + var sources = new IObservable[SourceCount]; + for (var i = 0; i < SourceCount; i++) + { + var index = i; + sources[i] = Observable.Defer(() => + { + runOrder.Add(index); + return Observable.Return(RxVoid.Default); + }); + } + + var emitted = 0; + var completed = false; + + using var sub = ((IReadOnlyList>)sources).RunAll() + .Subscribe(_ => emitted++, () => completed = true); + + await Assert.That(runOrder).IsCollectionEqualTo(BuildIndexSequence(SourceCount)); + await Assert.That(emitted).IsEqualTo(1); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that asynchronous sources complete in order before emitting. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRunAllAsyncSources_ThenWalksSequentially() + { + var subjectA = new Subject(); + var subjectB = new Subject(); + IObservable[] sources = [subjectA, subjectB]; + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = ((IReadOnlyList>)sources).RunAll() + .Subscribe(static _ => { }, () => completed.TrySetResult(true)); + + await Assert.That(subjectA.HasObservers).IsTrue(); + await Assert.That(subjectB.HasObservers).IsFalse(); + + subjectA.OnCompleted(); + + await Assert.That(subjectB.HasObservers).IsTrue(); + subjectB.OnCompleted(); + + var done = await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(done).IsTrue(); + } + + /// Verifies that an error in any source propagates and aborts the walk. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRunAllSourceErrors_ThenForwardsAndStops() + { + var subjectA = new Subject(); + var subjectB = new Subject(); + IObservable[] sources = [subjectA, subjectB]; + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = ((IReadOnlyList>)sources).RunAll() + .Subscribe(static _ => { }, ex => caught = ex); + + subjectA.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(subjectB.HasObservers).IsFalse(); + } + + /// Verifies that disposing mid-walk releases the active subscription. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRunAllDisposedMidWalk_ThenStops() + { + var subjectA = new Subject(); + var subjectB = new Subject(); + IObservable[] sources = [subjectA, subjectB]; + var completed = false; + + var sub = ((IReadOnlyList>)sources).RunAll() + .Subscribe(static _ => { }, () => completed = true); + + sub.Dispose(); + + // Second dispose hits the Interlocked.Exchange null-loser branch in Sink.Dispose — + // the first call swapped in null and disposed the previous subscription, so the + // second call sees null and the `?.Dispose()` no-op fires. + sub.Dispose(); + + subjectA.OnCompleted(); + + await Assert.That(completed).IsFalse(); + await Assert.That(subjectB.HasObservers).IsFalse(); + } + + /// 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(RxVoid.Default); + first.Observer.OnCompleted(); + first.Observer.OnNext(RxVoid.Default); + first.Observer.OnError(new InvalidOperationException("late")); + first.Observer.OnCompleted(); + + await Assert.That(completedCount).IsEqualTo(1); + await Assert.That(values).IsCollectionEqualTo([RxVoid.Default]); + await Assert.That(caught).IsNull(); + } + + /// Exercises RunAll.RunNext's post-loop _done guard — a source + /// that synchronously errors during Subscribe sets _done = true inline, + /// the while (!_done ...) loop bails, and the post-loop check returns without + /// emitting RxVoid.Default. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRunAllSourceSyncErrors_ThenPostLoopDoneGuardSuppressesFinalEmit() + { + IObservable[] sources = + [ + new SyncErroringObservable(new InvalidOperationException(SourceErrorMessage)), + ]; + Exception? caught = null; + var emitted = 0; + var completed = false; + + using var sub = ((IReadOnlyList>)sources).RunAll() + .Subscribe(_ => emitted++, ex => caught = ex, () => completed = true); + + await Assert.That(caught).IsNotNull(); + await Assert.That(caught!.Message).IsEqualTo(SourceErrorMessage); + await Assert.That(emitted).IsEqualTo(0); + await Assert.That(completed).IsFalse(); + } + + /// 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) + { + var output = new int[count]; + for (var i = 0; i < count; i++) + { + output[i] = i; + } + + return output; + } + + /// Synchronously-erroring observable used to drive the sync-error path of + /// RunAll.RunNext. + /// The element type. + private sealed class SyncErroringObservable(Exception error) : IObservable + { + /// + public IDisposable Subscribe(IObserver observer) + { + observer.OnError(error); + return System.Reactive.Disposables.Disposable.Empty; + } + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SampleLatestObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SampleLatestObservableTests.cs new file mode 100644 index 0000000..70d88e8 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SampleLatestObservableTests.cs @@ -0,0 +1,165 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Edge-case coverage for SampleLatest backed by +/// SampleLatestObservable<T> — trigger before any source value, +/// source completion, source error, trigger error, and trigger completion not +/// terminating downstream. +public class SampleLatestObservableTests +{ + /// Synthetic error message attached to source errors. + private const string SourceErrorMessage = "source error"; + + /// Synthetic error message attached to trigger errors. + private const string TriggerErrorMessage = "trigger error"; + + /// Trigger token reused across tests. + private static readonly object TriggerToken = new(); + + /// Verifies that a trigger arriving before any source value does not emit. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSampleLatestTriggerBeforeAnyValue_ThenNoEmission() + { + var source = new Subject(); + var trigger = new Subject(); + var results = new List(); + + using var sub = source.SampleLatest(trigger) + .Subscribe(results.Add); + + trigger.OnNext(TriggerToken); + + await Assert.That(results).IsEmpty(); + } + + /// Verifies that SampleLatest emits the latest source value on each trigger. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSampleLatestTriggerAfterValues_ThenEmitsLatest() + { + const int First = 1; + const int Second = 2; + var source = new Subject(); + var trigger = new Subject(); + var results = new List(); + + using var sub = source.SampleLatest(trigger) + .Subscribe(results.Add); + + source.OnNext(First); + source.OnNext(Second); + trigger.OnNext(TriggerToken); + trigger.OnNext(TriggerToken); + + await Assert.That(results).IsCollectionEqualTo([Second, Second]); + } + + /// Verifies that SampleLatest forwards source errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSampleLatestSourceErrors_ThenForwardsError() + { + var source = new Subject(); + var trigger = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = source.SampleLatest(trigger) + .Subscribe(static _ => { }, ex => caught = ex); + + source.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that SampleLatest forwards trigger errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSampleLatestTriggerErrors_ThenForwardsError() + { + var source = new Subject(); + var trigger = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(TriggerErrorMessage); + + using var sub = source.SampleLatest(trigger) + .Subscribe(static _ => { }, ex => caught = ex); + + trigger.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that source completion is forwarded downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSampleLatestSourceCompletes_ThenForwardsCompletion() + { + var source = new Subject(); + var trigger = new Subject(); + var completed = false; + + using var sub = source.SampleLatest(trigger) + .Subscribe(static _ => { }, () => completed = true); + + source.OnCompleted(); + + await Assert.That(completed).IsTrue(); + } + + /// Verifies that trigger completion alone does NOT complete the downstream sequence. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSampleLatestTriggerCompletes_ThenDownstreamRemainsOpen() + { + const int Value = 7; + var source = new Subject(); + var trigger = new Subject(); + var results = new List(); + var completed = false; + + using var sub = source.SampleLatest(trigger) + .Subscribe(results.Add, () => completed = true); + + source.OnNext(Value); + trigger.OnCompleted(); + + // After trigger completion, source values are still tracked. No emission, no termination. + 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.Primitives.Extensions.Tests/Operators/ScanWithInitialTests.Terminal.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScanWithInitialTests.Terminal.cs new file mode 100644 index 0000000..c5f85f7 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScanWithInitialTests.Terminal.cs @@ -0,0 +1,113 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Terminal-notification coverage for ScanWithInitial — source error, +/// source completion, and post-terminal value ignore. +public partial class ScanWithInitialTests +{ + /// Synthetic error message attached to source errors. + private const string SourceErrorMessage = "source error"; + + /// Initial accumulator value. + private const int TerminalInitial = 0; + + /// Verifies that OnError is forwarded after the initial value has been emitted. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScanSourceErrors_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.ScanWithInitial(TerminalInitial, static (acc, x) => acc + x) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that OnCompleted is forwarded. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScanSourceCompletes_ThenForwardsCompletion() + { + var subject = new Subject(); + var completed = false; + + using var sub = subject.ScanWithInitial(TerminalInitial, static (acc, x) => acc + x) + .Subscribe(static _ => { }, () => completed = true); + + subject.OnCompleted(); + + await Assert.That(completed).IsTrue(); + } + + /// Verifies that values arriving after OnError are ignored. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScanValueAfterError_ThenIgnored() + { + const int IgnoredValue = 5; + var subject = new Subject(); + var results = new List(); + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.ScanWithInitial(TerminalInitial, static (acc, x) => acc + x) + .Subscribe(results.Add, static _ => { }); + + subject.OnError(expected); + subject.OnNext(IgnoredValue); + + await Assert.That(results).IsCollectionEqualTo([TerminalInitial]); + } + + /// Verifies that values arriving after OnCompleted are ignored. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScanValueAfterCompleted_ThenIgnored() + { + const int IgnoredValue = 5; + var subject = new Subject(); + var results = new List(); + + using var sub = subject.ScanWithInitial(TerminalInitial, static (acc, x) => acc + x) + .Subscribe(results.Add); + + subject.OnCompleted(); + subject.OnNext(IgnoredValue); + + 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.Primitives.Extensions.Tests/Operators/ScanWithInitialTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScanWithInitialTests.cs new file mode 100644 index 0000000..a458b43 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScanWithInitialTests.cs @@ -0,0 +1,162 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// +/// Tests for the class. +/// +public partial class ScanWithInitialTests +{ +#if NET9_0_OR_GREATER + /// Synchronization gate used by tests. + private readonly Lock _gate = new(); +#else + /// Synchronization gate used by tests. + private readonly object _gate = new(); +#endif + + /// + /// Tests that emits the initial value immediately upon subscription. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Subscribe_EmitsInitialValueImmediately() + { + // Arrange + var source = new Subject(); + const int Initial = 10; + var accumulator = (int acc, int x) => + acc + x; + var observable = new ScanWithInitialObservable(source, Initial, accumulator); + var results = new List(); + + // Act + using (observable.Subscribe( + results.Add)) + { + // Assert + const int ExpectedInitial = 10; + await Assert.That(results).IsCollectionEqualTo([ExpectedInitial]); + } + } + + /// + /// Tests that accumulates values correctly. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task OnNext_AccumulatesValues() + { + // Arrange + var source = new Subject(); + const int Initial = 0; + var accumulator = (int acc, int x) => + acc + x; + var observable = new ScanWithInitialObservable(source, Initial, accumulator); + var results = new List(); + + // Act + using (observable.Subscribe( + results.Add)) + { + const int Second = 2; + const int Third = 3; + source.OnNext(1); + source.OnNext(Second); + source.OnNext(Third); + } + + // Assert + const int RunningSumAfterSecond = 3; + const int RunningSumAfterThird = 6; + await Assert.That(results).IsCollectionEqualTo([0, 1, RunningSumAfterSecond, RunningSumAfterThird]); + } + + /// + /// Tests that handles errors in the accumulator. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task AccumulatorError_PropagatesError() + { + // Arrange + var source = new Subject(); + const int Initial = 0; + Exception exception = new InvalidOperationException("Accumulator failed"); + Func accumulator = (_, _) => + throw exception; + var observable = new ScanWithInitialObservable(source, Initial, accumulator); + var errors = new List(); + + // Act + using (observable.Subscribe( + _ => { }, + errors.Add)) + { + source.OnNext(1); + } + + // Assert + await Assert.That(errors).IsCollectionEqualTo([exception]); + } + + /// + /// Tests that is thread-safe. + /// + /// A representing the asynchronous test operation. + [Test] + [SuppressMessage("Blocker Code Smell", "S4462:Calls to \"async\" methods should not be blocking", Justification = "Test is synchronous.")] + public async Task Observable_IsThreadSafe() + { + // Arrange + var source = new Subject(); + const int Initial = 0; + var accumulator = (int acc, int x) => + { + Thread.Sleep(1); // Force potential race condition + return acc + x; + }; + var observable = new ScanWithInitialObservable(source, Initial, accumulator); + var results = new List(); + var completedCount = 0; + + // Act + using (observable.Subscribe( + x => + { + lock (_gate) + { + results.Add(x); + } + }, + _ => { }, + () => Interlocked.Increment(ref completedCount))) + { + var t1 = Task.Run(() => + { + for (var i = 0; i < 100; i++) + { + source.OnNext(i); + } + }); + + var t2 = Task.Run(async () => + { + await Task.Delay(50); + source.OnCompleted(); + }); + + await Task.WhenAll(t1, t2); + } + + // Assert + // We can't easily assert the exact sequence due to the non-thread-safe Subject, + // but we can assert that it didn't crash and the state remains consistent. + // The lock in ScanWithInitialSink ensures that OnNext doesn't race with OnCompleted internally. + await Assert.That(completedCount).IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScheduledAndDebounceSyncOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScheduledAndDebounceSyncOperatorTests.cs new file mode 100644 index 0000000..90f0565 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScheduledAndDebounceSyncOperatorTests.cs @@ -0,0 +1,450 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Linq; +using System.Reactive.Subjects; +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.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 VirtualClock(); + 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 VirtualClock(); + 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 VirtualClock(); + 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 VirtualClock(); + 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 VirtualClock(); + 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 VirtualClock(); + 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 VirtualClock(); + 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 VirtualClock(); + 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, ISequencer) 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 VirtualClock(); + 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, ISequencer) emits at the absolute time. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScheduleValueAbsolute_ThenEmitsAtTime() + { + var scheduler = new VirtualClock(); + 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, ISequencer) + /// 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 VirtualClock(); + 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, ISequencer) + /// dispatches each OnNext at the absolute time. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScheduleSourceAbsolute_ThenEmitsAtTime() + { + var scheduler = new VirtualClock(); + 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.Primitives.Extensions.Tests/Operators/ScheduledSourceObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScheduledSourceObservableTests.cs new file mode 100644 index 0000000..dda747e --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScheduledSourceObservableTests.cs @@ -0,0 +1,112 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.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 VirtualClock(); + 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 VirtualClock(); + 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 VirtualClock(); + 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 VirtualClock(); + 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.Primitives.Extensions.Tests/Operators/SelectAsyncConcurrentObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectAsyncConcurrentObservableTests.cs new file mode 100644 index 0000000..607e95a --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectAsyncConcurrentObservableTests.cs @@ -0,0 +1,167 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Edge-case coverage for SelectAsyncConcurrent backed by +/// SelectAsyncConcurrentObservable<TSource, TResult> — error forwarding, +/// disposal mid-flight, and deferred completion while in-flight selectors finish. +public class SelectAsyncConcurrentObservableTests +{ + /// Synthetic error message attached to a failing selector. + private const string SelectorErrorMessage = "selector failed"; + + /// Synthetic error message attached to source errors. + private const string SourceErrorMessage = "source error"; + + /// Settle delay in milliseconds used to let an awaited continuation attempt delivery. + private const int SettleDelayMilliseconds = 50; + + /// Max concurrency used for two-in-flight tests. + private const int MaxConcurrencyTwo = 2; + + /// Max concurrency used for four-in-flight tests. + private const int MaxConcurrencyFour = 4; + + /// Verifies that SelectAsyncConcurrent forwards selector exceptions. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectAsyncConcurrentSelectorThrows_ThenForwardsError() + { + const int TriggerValue = 1; + var subject = new Subject(); + var faulted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var expected = new InvalidOperationException(SelectorErrorMessage); + + using var sub = subject.SelectAsyncConcurrent( + _ => Task.FromException(expected), + maxConcurrency: MaxConcurrencyTwo) + .Subscribe(static _ => { }, ex => faulted.TrySetResult(ex)); + + subject.OnNext(TriggerValue); + + var caught = await faulted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that SelectAsyncConcurrent forwards source errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectAsyncConcurrentSourceErrors_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.SelectAsyncConcurrent(static x => Task.FromResult(x), maxConcurrency: MaxConcurrencyTwo) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that disposing the subscription mid-flight suppresses + /// further emissions and completion. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectAsyncConcurrentDisposedMidFlight_ThenSuppressesEmissionAndCompletion() + { + const int TriggerValue = 1; + var subject = new Subject(); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var results = new List(); + var completed = false; + + var sub = subject.SelectAsyncConcurrent( + async x => + { + await gate.Task.ConfigureAwait(false); + return x; + }, + maxConcurrency: MaxConcurrencyTwo) + .Subscribe(results.Add, () => completed = true); + + subject.OnNext(TriggerValue); + subject.OnCompleted(); + sub.Dispose(); + gate.TrySetResult(true); + + await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + + await Assert.That(results).IsEmpty(); + await Assert.That(completed).IsFalse(); + } + + /// Verifies that completion arriving while selectors are still in flight + /// is forwarded after all selectors finish. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectAsyncConcurrentCompletesWithInFlight_ThenDeferredCompletion() + { + const int First = 1; + const int Second = 2; + var subject = new Subject(); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var results = new List(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = subject.SelectAsyncConcurrent( + async x => + { + await gate.Task.ConfigureAwait(false); + return x; + }, + maxConcurrency: MaxConcurrencyFour) + .Subscribe( + results.Add, + () => completed.TrySetResult(true)); + + subject.OnNext(First); + subject.OnNext(Second); + subject.OnCompleted(); + + // The selector is gated; nothing should have emitted yet. + await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + await Assert.That(completed.Task.IsCompleted).IsFalse(); + + gate.TrySetResult(true); + + var done = await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(done).IsTrue(); + + // Downstream OnNext from this operator is serialized inside the sink's lock, so the + // list is safely populated by the time completion fires. Order is concurrent so sort. + int[] sorted = [.. results]; + 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.Primitives.Extensions.Tests/Operators/SelectAsyncSequentialObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectAsyncSequentialObservableTests.cs new file mode 100644 index 0000000..ad92e89 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectAsyncSequentialObservableTests.cs @@ -0,0 +1,149 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Edge-case coverage for SelectAsyncSequential backed by +/// SelectAsyncSequentialObservable<TSource, TResult> — error forwarding, +/// disposal mid-flight, and completion while an in-flight selector is running. +public class SelectAsyncSequentialObservableTests +{ + /// Synthetic error message attached to a failing selector. + private const string SelectorErrorMessage = "selector failed"; + + /// Synthetic error message attached to source errors. + private const string SourceErrorMessage = "source error"; + + /// Settle delay in milliseconds used to let an awaited continuation attempt delivery. + private const int SettleDelayMilliseconds = 50; + + /// Verifies that SelectAsyncSequential forwards selector exceptions + /// and stops draining the queue afterwards. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectAsyncSequentialSelectorThrows_ThenForwardsErrorAndStops() + { + const int First = 1; + const int Second = 2; + var subject = new Subject(); + var faulted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var results = new List(); + var expected = new InvalidOperationException(SelectorErrorMessage); + + using var sub = subject.SelectAsyncSequential(x => + x == First ? Task.FromException(expected) : Task.FromResult(x)) + .Subscribe(results.Add, ex => faulted.TrySetResult(ex)); + + subject.OnNext(First); + subject.OnNext(Second); + + var caught = await faulted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(results).IsEmpty(); + } + + /// Verifies that SelectAsyncSequential forwards source errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectAsyncSequentialSourceErrors_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.SelectAsyncSequential(static x => Task.FromResult(x)) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that disposing the subscription mid-flight suppresses + /// further emissions and completion. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectAsyncSequentialDisposedMidFlight_ThenSuppressesEmissionAndCompletion() + { + const int TriggerValue = 1; + var subject = new Subject(); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var results = new List(); + var completed = false; + + var sub = subject.SelectAsyncSequential(async x => + { + await gate.Task.ConfigureAwait(false); + return x; + }).Subscribe(results.Add, () => completed = true); + + subject.OnNext(TriggerValue); + subject.OnCompleted(); + sub.Dispose(); + gate.TrySetResult(true); + + await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + + await Assert.That(results).IsEmpty(); + await Assert.That(completed).IsFalse(); + } + + /// Verifies that completion arriving while a selector is in flight + /// is forwarded after the in-flight selector finishes. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectAsyncSequentialCompletesWhileProcessing_ThenDeferredCompletion() + { + const int Value = 42; + var subject = new Subject(); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var results = new List(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = subject.SelectAsyncSequential(async x => + { + await gate.Task.ConfigureAwait(false); + return x; + }).Subscribe(results.Add, () => completed.TrySetResult(true)); + + subject.OnNext(Value); + subject.OnCompleted(); + + // Completion must not fire while selector is gated. + await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + await Assert.That(completed.Task.IsCompleted).IsFalse(); + + gate.TrySetResult(true); + + var done = await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + 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.Primitives.Extensions.Tests/Operators/SelectLatestAsyncObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectLatestAsyncObservableTests.cs new file mode 100644 index 0000000..e6237fd --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectLatestAsyncObservableTests.cs @@ -0,0 +1,177 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Edge-case coverage for SelectLatestAsync backed by +/// SelectLatestAsyncObservable<TSource, TResult> — error forwarding, +/// disposal mid-flight, stale-id drop path and completion-after-in-flight. +public class SelectLatestAsyncObservableTests +{ + /// Synthetic error message attached to a failing selector. + private const string SelectorErrorMessage = "selector failed"; + + /// Synthetic error message attached to source errors. + private const string SourceErrorMessage = "source error"; + + /// Settle delay in milliseconds used to let an awaited continuation attempt delivery. + private const int SettleDelayMilliseconds = 50; + + /// Poll interval in milliseconds used while waiting for an emission. + private const int PollIntervalMilliseconds = 10; + + /// Multiplier applied inside the projection selector. + private const int ProjectionMultiplier = 10; + + /// Verifies that SelectLatestAsync forwards selector exceptions. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectLatestAsyncSelectorThrows_ThenForwardsError() + { + const int TriggerValue = 1; + var subject = new Subject(); + var faulted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var expected = new InvalidOperationException(SelectorErrorMessage); + + using var sub = subject.SelectLatestAsync(_ => Task.FromException(expected)) + .Subscribe(static _ => { }, ex => faulted.TrySetResult(ex)); + + subject.OnNext(TriggerValue); + + var caught = await faulted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that SelectLatestAsync forwards source errors immediately. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectLatestAsyncSourceErrors_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.SelectLatestAsync(static x => Task.FromResult(x)) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that disposing the subscription before the selector completes + /// suppresses any later OnNext / OnCompleted. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectLatestAsyncDisposedMidFlight_ThenSuppressesEmissionAndCompletion() + { + const int TriggerValue = 1; + var subject = new Subject(); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var results = new List(); + var completed = false; + + var sub = subject.SelectLatestAsync(async x => + { + await gate.Task.ConfigureAwait(false); + return x * 2; + }).Subscribe(results.Add, () => completed = true); + + subject.OnNext(TriggerValue); + subject.OnCompleted(); + sub.Dispose(); + gate.TrySetResult(true); + + // Give the awaited continuation a chance to attempt delivery. + await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + + await Assert.That(results).IsEmpty(); + await Assert.That(completed).IsFalse(); + } + + /// Verifies that a newer value supersedes a slower in-flight projection, + /// so only the latest result is emitted. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectLatestAsyncNewerArrives_ThenOlderResultDropped() + { + const int Slow = 1; + const int Fast = 2; + var subject = new Subject(); + var slowGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var results = new List(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = subject.SelectLatestAsync(async x => + { + if (x == Slow) + { + await slowGate.Task.ConfigureAwait(false); + } + + return x * ProjectionMultiplier; + }).Subscribe(results.Add, () => completed.TrySetResult(true)); + + subject.OnNext(Slow); + subject.OnNext(Fast); + + // Wait for the fast projection to complete and emit. + while (results.Count == 0) + { + await Task.Delay(PollIntervalMilliseconds).ConfigureAwait(false); + } + + slowGate.TrySetResult(true); + subject.OnCompleted(); + + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + // Only the latest (Fast) projection's result should appear. + await Assert.That(results).IsCollectionEqualTo([Fast * ProjectionMultiplier]); + } + + /// Verifies that source completion before any value still completes downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectLatestAsyncSourceCompletesWithNoValues_ThenForwardsCompletion() + { + var subject = new Subject(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = subject.SelectLatestAsync(static x => Task.FromResult(x)) + .Subscribe(static _ => { }, () => completed.TrySetResult(true)); + + subject.OnCompleted(); + + 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.Primitives.Extensions.Tests/Operators/SelectManyThenObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectManyThenObservableTests.cs new file mode 100644 index 0000000..272d9e1 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectManyThenObservableTests.cs @@ -0,0 +1,148 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Linq; +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Edge-case coverage for SelectManyThen backed by +/// SelectManyThenObservable<TSource, TMid, TResult> — two-stage projection, +/// first/second projection throws, source error/completion, and inner-observable errors. +public class SelectManyThenObservableTests +{ + /// Synthetic error messages. + private const string SourceErrorMessage = "source error"; + + /// Synthetic error for the first projection. + private const string FirstProjectionFailedMessage = "first failed"; + + /// Synthetic error for the second projection. + private const string SecondProjectionFailedMessage = "second failed"; + + /// Synthetic error for the inner observable. + private const string InnerErrorMessage = "inner error"; + + /// Multiplier used to derive intermediate values from the source. + private const int IntermediateMultiplier = 10; + + /// Multiplier used to derive final values from the intermediate. + private const int FinalMultiplier = 7; + + /// Source sentinel value. + private const int SourceValue = 3; + + /// Verifies that values flow through both projections to downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectManyThenTwoStages_ThenComposesProjections() + { + var results = new List(); + + using var sub = Observable.Return(SourceValue) + .SelectManyThen( + static x => Observable.Return(x * IntermediateMultiplier), + static mid => Observable.Return(mid * FinalMultiplier)) + .Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo([SourceValue * IntermediateMultiplier * FinalMultiplier]); + } + + /// Verifies that a throwing first projection forwards the error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectManyThenFirstThrows_ThenForwardsError() + { + Exception? caught = null; + var expected = new InvalidOperationException(FirstProjectionFailedMessage); + + using var sub = Observable.Return(SourceValue) + .SelectManyThen( + _ => throw expected, + static _ => Observable.Return(0)) + .Subscribe(static _ => { }, ex => caught = ex); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that a throwing second projection forwards the error. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectManyThenSecondThrows_ThenForwardsError() + { + Exception? caught = null; + var expected = new InvalidOperationException(SecondProjectionFailedMessage); + + using var sub = Observable.Return(SourceValue) + .SelectManyThen( + static x => Observable.Return(x), + _ => throw expected) + .Subscribe(static _ => { }, ex => caught = ex); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that a source error is forwarded. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectManyThenSourceErrors_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.SelectManyThen( + static x => Observable.Return(x), + static x => Observable.Return(x)) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that a source completion is forwarded. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectManyThenSourceCompletes_ThenForwardsCompletion() + { + var subject = new Subject(); + var completed = false; + + using var sub = subject.SelectManyThen( + static x => Observable.Return(x), + static x => Observable.Return(x)) + .Subscribe(static _ => { }, () => completed = true); + + subject.OnCompleted(); + + await Assert.That(completed).IsTrue(); + } + + /// Verifies that an inner-observable error is forwarded. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectManyThenInnerErrors_ThenForwardsError() + { + Exception? caught = null; + var expected = new InvalidOperationException(InnerErrorMessage); + + using var sub = Observable.Return(SourceValue) + .SelectManyThen( + static _ => Observable.Throw(new InvalidOperationException("first-inner")), + static x => Observable.Return(x)) + .Subscribe(static _ => { }, ex => caught = ex); + + await Assert.That(caught).IsNotNull(); + + Exception? caughtSecond = null; + using var sub2 = Observable.Return(SourceValue) + .SelectManyThen( + static x => Observable.Return(x), + _ => Observable.Throw(expected)) + .Subscribe(static _ => { }, ex => caughtSecond = ex); + + await Assert.That(caughtSecond).IsSameReferenceAs(expected); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SimpleSyncOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SimpleSyncOperatorTests.cs new file mode 100644 index 0000000..4cf80aa --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SimpleSyncOperatorTests.cs @@ -0,0 +1,231 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; +using System.Text.RegularExpressions; + +namespace ReactiveUI.Primitives.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.Primitives.Extensions.Tests/Operators/StartActionObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/StartActionObservableTests.cs new file mode 100644 index 0000000..6f3f350 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/StartActionObservableTests.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. + +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Edge-case coverage for the action-form Start operator backed by +/// StartActionObservable — synchronous inline path, scheduler dispatch, +/// and action-throws forwarding. +public class StartActionObservableTests +{ + /// Synthetic error message attached to action failures. + private const string ActionFailedMessage = "action failed"; + + /// Verifies that Start with a null scheduler runs synchronously and completes. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenStartActionInline_ThenRunsAndCompletes() + { + var ran = false; + var completed = false; + var emitted = 0; + + using var sub = ReactiveExtensions.Start(() => ran = true, scheduler: null) + .Subscribe(_ => emitted++, () => completed = true); + + await Assert.That(ran).IsTrue(); + await Assert.That(emitted).IsEqualTo(1); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that Start with a scheduler dispatches the action via that scheduler. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenStartActionWithScheduler_ThenRunsViaScheduler() + { + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var ran = false; + + using var sub = ReactiveExtensions.Start(() => ran = true, TaskPoolSequencer.Default) + .Subscribe(static _ => { }, () => completed.TrySetResult(true)); + + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(ran).IsTrue(); + } + + /// Verifies that an exception thrown by the inline action is forwarded to OnError. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenStartActionThrowsInline_ThenForwardsError() + { + Exception? caught = null; + var expected = new InvalidOperationException(ActionFailedMessage); + + using var sub = ReactiveExtensions.Start(() => throw expected, scheduler: null) + .Subscribe(static _ => { }, ex => caught = ex); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that an exception thrown by the scheduled action is forwarded to OnError. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenStartActionThrowsScheduled_ThenForwardsError() + { + var faulted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var expected = new InvalidOperationException(ActionFailedMessage); + + using var sub = ReactiveExtensions.Start(() => throw expected, TaskPoolSequencer.Default) + .Subscribe(static _ => { }, ex => faulted.TrySetResult(ex)); + + var caught = await faulted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/StartFuncObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/StartFuncObservableTests.cs new file mode 100644 index 0000000..e03ea7c --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/StartFuncObservableTests.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. + +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Edge-case coverage for the Start(Func{TResult}, ISequencer?) overload +/// backed by StartFuncObservable<TResult> — paths missed by the happy-path tests +/// (inline vs scheduler dispatch and function-throws on both paths). +public class StartFuncObservableTests +{ + /// Result returned by the Start tests. + private const int StartResult = 17; + + /// Message attached to a thrown Start function. + private const string FunctionFailedMessage = "function failed"; + + /// Verifies that the inline (null-scheduler) overload runs the function, emits the result and completes. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenStartFuncInline_ThenEmitsResultAndCompletes() + { + var results = new List(); + var completed = false; + + using var sub = ReactiveExtensions.Start(static () => StartResult, null) + .Subscribe(results.Add, () => completed = true); + + await Assert.That(results).IsCollectionEqualTo([StartResult]); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that the scheduler overload defers execution but still emits and completes. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenStartFuncOnScheduler_ThenRunsOnSchedulerAndCompletes() + { + var results = new List(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = ReactiveExtensions.Start(static () => StartResult, Sequencer.Default) + .Subscribe(results.Add, () => completed.TrySetResult()); + + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(results).IsCollectionEqualTo([StartResult]); + } + + /// Verifies that an exception thrown by the function is surfaced as OnError. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenStartFuncThrows_ThenForwardsError() + { + Exception? caught = null; + var expected = new InvalidOperationException(FunctionFailedMessage); + + using var sub = ReactiveExtensions.Start((Func)(() => throw expected), null) + .Subscribe(static _ => { }, ex => caught = ex); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that the scheduler path also forwards function errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenStartFuncOnSchedulerThrows_ThenForwardsError() + { + var faulted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var expected = new InvalidOperationException(FunctionFailedMessage); + + using var sub = ReactiveExtensions.Start((Func)(() => throw expected), Sequencer.Default) + .Subscribe(static _ => { }, ex => faulted.TrySetResult(ex)); + + var caught = await faulted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SubscribeAsyncObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SubscribeAsyncObservableTests.cs new file mode 100644 index 0000000..1e17f2e --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SubscribeAsyncObservableTests.cs @@ -0,0 +1,269 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Edge-case coverage for the SubscribeSynchronous / SubscribeAsync +/// overloads backed by SubscribeAsyncObservable<T> — sequential handler invocation, +/// handler-throws forwards via onError, completion-while-processing defers, disposal stops queue. +public class SubscribeAsyncObservableTests +{ + /// Synthetic error message attached to handler failures. + private const string HandlerFailedMessage = "handler failed"; + + /// Synthetic error message attached to source errors. + private const string SourceErrorMessage = "source error"; + + /// Settle delay in milliseconds used to confirm completion is deferred. + private const int SettleDelayMilliseconds = 50; + + /// Verifies that values are handled in order and completion fires. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscribeAsyncProcessesValues_ThenInOrder() + { + const int First = 1; + const int Second = 2; + var subject = new Subject(); + var results = new List(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = subject.SubscribeSynchronous( + x => + { + results.Add(x); + return default; + }, + static _ => { }, + () => completed.TrySetResult(true)); + + subject.OnNext(First); + subject.OnNext(Second); + subject.OnCompleted(); + + var done = await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(done).IsTrue(); + await Assert.That(results).IsCollectionEqualTo([First, Second]); + } + + /// Verifies that a handler exception is forwarded to the error callback. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscribeAsyncHandlerThrows_ThenForwardsToOnError() + { + const int TriggerValue = 1; + var subject = new Subject(); + var faulted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var expected = new InvalidOperationException(HandlerFailedMessage); + + using var sub = subject.SubscribeSynchronous( + _ => ValueTask.FromException(expected), + ex => faulted.TrySetResult(ex)); + + subject.OnNext(TriggerValue); + + var caught = await faulted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies a handler exception is swallowed when no error callback is supplied. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscribeAsyncHandlerThrowsWithoutOnError_ThenNoExceptionEscapes() + { + const int TriggerValue = 1; + var subject = new Subject(); + var handlerRan = 0; + + using var sub = subject.SubscribeSynchronous(_ => + { + handlerRan++; + return ValueTask.FromException(new InvalidOperationException(HandlerFailedMessage)); + }); + + subject.OnNext(TriggerValue); + + await Assert.That(handlerRan).IsEqualTo(1); + } + + /// Verifies that source errors are forwarded to the error callback. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscribeAsyncSourceErrors_ThenForwardsToOnError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.SubscribeSynchronous( + static _ => default, + ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that completion arriving while a handler is in flight defers + /// completion until the handler finishes. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscribeAsyncCompletesWhileProcessing_ThenDeferredCompletion() + { + const int Value = 7; + var subject = new Subject(); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = subject.SubscribeSynchronous( + async _ => await gate.Task.ConfigureAwait(false), + () => completed.TrySetResult(true)); + + subject.OnNext(Value); + subject.OnCompleted(); + + await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + await Assert.That(completed.Task.IsCompleted).IsFalse(); + + gate.TrySetResult(true); + + var done = await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(done).IsTrue(); + } + + /// Verifies deferred completion with no completion callback takes the null-callback path. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscribeAsyncCompletesWhileProcessingWithoutCallback_ThenNoExceptionEscapes() + { + const int Value = 7; + var subject = new Subject(); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var handled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = subject.SubscribeSynchronous(async _ => + { + await gate.Task.ConfigureAwait(false); + handled.TrySetResult(true); + }); + + subject.OnNext(Value); + subject.OnCompleted(); + gate.TrySetResult(true); + + var done = await handled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + await Assert.That(done).IsTrue(); + } + + /// Verifies disposal during an in-flight handler suppresses deferred terminal callbacks. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscribeAsyncDisposedDuringInFlight_ThenSuppressesCompletionAndError() + { + const int Value = 7; + var subject = new Subject(); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var handlerStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Exception? caught = null; + var completedCount = 0; + + var sub = subject.SubscribeSynchronous( + async _ => + { + handlerStarted.TrySetResult(true); + await gate.Task.ConfigureAwait(false); + throw new InvalidOperationException(HandlerFailedMessage); + }, + ex => caught = ex, + () => completedCount++); + + subject.OnNext(Value); + await handlerStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + subject.OnCompleted(); + sub.Dispose(); + gate.TrySetResult(true); + await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + + await Assert.That(caught).IsNull(); + await Assert.That(completedCount).IsEqualTo(0); + } + + /// Verifies that disposing the subscription stops further handler invocations. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscribeAsyncDisposed_ThenStopsProcessing() + { + const int IgnoredValue = 1; + var subject = new Subject(); + var handlerRan = 0; + + var sub = subject.SubscribeSynchronous(_ => + { + Interlocked.Increment(ref handlerRan); + return default; + }); + + sub.Dispose(); + subject.OnNext(IgnoredValue); + + 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 default; + }, + 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 _ => default, + 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.Primitives.Extensions.Tests/Operators/SyncDirectSource.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SyncDirectSource.cs new file mode 100644 index 0000000..15989d4 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SyncDirectSource.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.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.Primitives.Extensions.Tests/Operators/SyncOperatorErrorForwardingTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SyncOperatorErrorForwardingTests.cs new file mode 100644 index 0000000..cf25fe5 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SyncOperatorErrorForwardingTests.cs @@ -0,0 +1,132 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; +using System.Text.RegularExpressions; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Direct coverage for the trivial OnError forwarders on a cluster of small +/// synchronous operators. Each method is a one-liner that hands a source error straight to +/// the downstream observer; the existing happy-path tests never exercised the error branch. +public class SyncOperatorErrorForwardingTests +{ + /// Synthetic error message used by every forwarder test. + private const string ForwardedMessage = "forwarded"; + + /// Verifies TrySelectObservable forwards source errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTrySelectSourceErrors_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(ForwardedMessage); + + using var sub = subject.TrySelect(static x => (int?)x) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies WhereTrueObservable forwards source errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhereTrueSourceErrors_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(ForwardedMessage); + + using var sub = subject.WhereTrue().Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies WhereFalseObservable forwards source errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhereFalseSourceErrors_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(ForwardedMessage); + + using var sub = subject.WhereFalse().Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies WhereIsNotNullObservable forwards source errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhereIsNotNullSourceErrors_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(ForwardedMessage); + + using var sub = subject.WhereIsNotNull().Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies SkipWhileNullObservable forwards source errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSkipWhileNullSourceErrors_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(ForwardedMessage); + + using var sub = subject.SkipWhileNull().Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies FilterRegexObservable forwards source errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFilterRegexSourceErrors_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(ForwardedMessage); + + using var sub = subject.Filter(new Regex("x", RegexOptions.None, TimeSpan.FromSeconds(1))) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies SelectConstantObservable forwards source errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectConstantSourceErrors_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(ForwardedMessage); + + using var sub = subject.SelectConstant("constant") + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SyncTimerObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SyncTimerObservableTests.cs new file mode 100644 index 0000000..0b66ec7 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SyncTimerObservableTests.cs @@ -0,0 +1,63 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.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 VirtualClock(); + 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); + var secondTicksBeforeDispose = secondTicks; + subSecond.Dispose(); + scheduler.AdvanceBy(period.Ticks); + + await Assert.That(firstTicks).IsGreaterThanOrEqualTo(1); + await Assert.That(thirdTicks).IsGreaterThanOrEqualTo(1); + await Assert.That(secondTicks).IsEqualTo(secondTicksBeforeDispose); + } + + /// Verifies that disposing a subscription twice is idempotent. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscriptionDisposedTwice_ThenIdempotent() + { + var scheduler = new VirtualClock(); + 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.Primitives.Extensions.Tests/Operators/SynchronizeAsyncObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SynchronizeAsyncObservableTests.cs new file mode 100644 index 0000000..4cf43a2 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SynchronizeAsyncObservableTests.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. + +namespace ReactiveUI.Primitives.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); + } + + /// Verifies the per-emission Sync signal latches on first dispose so a second + /// dispose by the consumer is a silent no-op. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSyncSignalDisposedTwice_ThenSecondDisposeIsNoOp() + { + var source = new SyncDirectSource(); + var processed = 0; + + using var sub = source.SynchronizeAsync() + .Subscribe(t => + { + t.Sync.Dispose(); + t.Sync.Dispose(); + processed++; + }); + + source.Observer.OnNext(1); + + await Task.Delay(SettleDelayMilliseconds); + await Assert.That(processed).IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleAndWaitOperatorCoverageTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleAndWaitOperatorCoverageTests.cs new file mode 100644 index 0000000..d9d1b74 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleAndWaitOperatorCoverageTests.cs @@ -0,0 +1,336 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Edge-case coverage for ThrottleOnScheduler, WaitUntil, +/// TakeUntilInclusive, and SynchronizeAsync — paths the happy-path +/// tests do not reach (error propagation, predicate throws, dispose, completion-flush). +public class ThrottleAndWaitOperatorCoverageTests +{ + /// Throttle interval used across the scheduler-driven tests. + private const int ThrottleTicks = 100; + + /// Tick advance guaranteed to fire any pending throttle scheduling. + private const int AdvancePastWindowTicks = 200; + + /// Message used for synthesised source exceptions across the error-propagation tests. + private const string SourceErrorMessage = "source error"; + + /// Message used for synthesised predicate-throws exceptions. + private const string PredicateFailedMessage = "predicate failed"; + + /// Sample value 1 used by the predicate-walk tests. + private const int Sample1 = 1; + + /// Sample value 2 used by the predicate-walk tests. + private const int Sample2 = 2; + + /// Sample value 3 used by the predicate-walk tests (also the inclusive match boundary). + private const int Sample3 = 3; + + /// Sample value 4 used by the predicate-walk tests (ignored once the match completes). + private const int Sample4 = 4; + + /// Match threshold large enough that no Sample value satisfies the predicate. + private const int NoMatchThreshold = 100; + + /// Value that does satisfy so the dispose test can confirm + /// nothing is delivered after disposal. + private const int MatchValue = 500; + + /// Verifies that ThrottleOnScheduler forwards source errors and cancels the pending emission. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleOnSchedulerSourceErrors_ThenForwardsErrorAndDropsPending() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.ThrottleOnScheduler(TimeSpan.FromTicks(ThrottleTicks), scheduler) + .Subscribe(results.Add, ex => caught = ex); + + subject.OnNext(Sample1); + subject.OnError(expected); + scheduler.AdvanceBy(AdvancePastWindowTicks); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(results).IsEmpty(); + } + + /// Verifies that completion with a pending value flushes the latest value before completing. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleOnSchedulerCompletesWithPending_ThenFlushesLatestThenCompletes() + { + const int Pending = 7; + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + var completed = false; + + using var sub = subject.ThrottleOnScheduler(TimeSpan.FromTicks(ThrottleTicks), scheduler) + .Subscribe(results.Add, () => completed = true); + + subject.OnNext(Pending); + subject.OnCompleted(); + + await Assert.That(results).IsCollectionEqualTo([Pending]); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that completion with no pending value just completes (no flushed value). + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleOnSchedulerCompletesEmpty_ThenCompletesWithoutValue() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + var completed = false; + + using var sub = subject.ThrottleOnScheduler(TimeSpan.FromTicks(ThrottleTicks), scheduler) + .Subscribe(results.Add, () => completed = true); + + subject.OnCompleted(); + + await Assert.That(results).IsEmpty(); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that disposing a throttle subscription stops further deliveries even when a scheduled + /// emission was already enqueued. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleOnSchedulerDisposed_ThenScheduledEmissionDropped() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + + var sub = subject.ThrottleOnScheduler(TimeSpan.FromTicks(ThrottleTicks), scheduler) + .Subscribe(results.Add); + + subject.OnNext(Sample1); + sub.Dispose(); + scheduler.AdvanceBy(AdvancePastWindowTicks); + + await Assert.That(results).IsEmpty(); + } + + /// Verifies WaitUntil forwards source errors when no match has occurred yet. + /// 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 x => x > NoMatchThreshold) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnNext(Sample1); + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies WaitUntil completes if the source completes before any element matches. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitUntilSourceCompletesWithoutMatch_ThenCompletesWithoutValue() + { + var subject = new Subject(); + var results = new List(); + var completed = false; + + using var sub = subject.WaitUntil(static x => x > NoMatchThreshold) + .Subscribe(results.Add, () => completed = true); + + subject.OnNext(Sample1); + subject.OnNext(Sample2); + subject.OnCompleted(); + + await Assert.That(results).IsEmpty(); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that a throwing predicate is surfaced as an OnError and stops further deliveries. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitUntilPredicateThrows_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var results = new List(); + var expected = new InvalidOperationException(PredicateFailedMessage); + + using var sub = subject.WaitUntil((Func)(_ => throw expected)) + .Subscribe(results.Add, ex => caught = ex); + + subject.OnNext(Sample1); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(results).IsEmpty(); + } + + /// Verifies that disposing a WaitUntil subscription before a match stops downstream emissions. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWaitUntilDisposedBeforeMatch_ThenNoDownstreamEmission() + { + var subject = new Subject(); + var results = new List(); + var completed = false; + + var sub = subject.WaitUntil(static x => x > NoMatchThreshold) + .Subscribe(results.Add, () => completed = true); + + sub.Dispose(); + subject.OnNext(MatchValue); + + await Assert.That(results).IsEmpty(); + await Assert.That(completed).IsFalse(); + } + + /// Verifies that TakeUntilInclusive emits the matching element then completes. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTakeUntilInclusivePredicateMatches_ThenEmitsThroughMatchAndCompletes() + { + var subject = new Subject(); + var results = new List(); + var completed = false; + + using var sub = subject.TakeUntil(static x => x >= Sample3) + .Subscribe(results.Add, () => completed = true); + + subject.OnNext(Sample1); + subject.OnNext(Sample2); + subject.OnNext(Sample3); + subject.OnNext(Sample4); + + await Assert.That(results).IsCollectionEqualTo([Sample1, Sample2, Sample3]); + await Assert.That(completed).IsTrue(); + } + + /// Verifies that TakeUntilInclusive forwards source errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTakeUntilInclusiveSourceErrors_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.TakeUntil(static x => x >= NoMatchThreshold) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnNext(Sample1); + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that a throwing predicate on TakeUntilInclusive surfaces as OnError. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTakeUntilInclusivePredicateThrows_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var results = new List(); + var expected = new InvalidOperationException(PredicateFailedMessage); + + using var sub = subject.TakeUntil((Func)(_ => throw expected)) + .Subscribe(results.Add, ex => caught = ex); + + subject.OnNext(Sample1); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(results).IsEmpty(); + } + + /// Verifies that TakeUntilInclusive completes when the source completes before any match. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTakeUntilInclusiveSourceCompletesBeforeMatch_ThenCompletes() + { + var subject = new Subject(); + var results = new List(); + var completed = false; + + using var sub = subject.TakeUntil(static x => x >= NoMatchThreshold) + .Subscribe(results.Add, () => completed = true); + + subject.OnNext(Sample1); + subject.OnNext(Sample2); + subject.OnCompleted(); + + await Assert.That(results).IsCollectionEqualTo([Sample1, Sample2]); + await Assert.That(completed).IsTrue(); + } + + /// Verifies SynchronizeAsync forwards source errors to the downstream observer. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSynchronizeAsyncSourceErrors_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.SynchronizeAsync() + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies SynchronizeAsync forwards completion to the downstream observer. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSynchronizeAsyncSourceCompletes_ThenForwardsCompletion() + { + var subject = new Subject(); + var completed = false; + + using var sub = subject.SynchronizeAsync() + .Subscribe(static _ => { }, () => completed = true); + + subject.OnCompleted(); + + await Assert.That(completed).IsTrue(); + } + + /// Verifies that disposing a SynchronizeAsync subscription suppresses subsequent emissions. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSynchronizeAsyncDisposed_ThenSuppressesSubsequentEmissions() + { + var subject = new Subject(); + var emissions = 0; + + var sub = subject.SynchronizeAsync() + .Subscribe(tuple => + { + Interlocked.Increment(ref emissions); + tuple.Sync.Dispose(); + }); + + sub.Dispose(); + subject.OnNext(Sample1); + subject.OnNext(Sample2); + + await Assert.That(emissions).IsEqualTo(0); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleDistinctObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleDistinctObservableTests.cs new file mode 100644 index 0000000..c191a84 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.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 ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.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 VirtualClock(); + 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 VirtualClock(); + 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.Primitives.Extensions.Tests/Operators/ThrottleFirstObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleFirstObservableTests.cs new file mode 100644 index 0000000..2ad4269 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleFirstObservableTests.cs @@ -0,0 +1,101 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Edge-case coverage for ThrottleFirst backed by +/// ThrottleFirstObservable<T> — error/completion forwarding and +/// post-terminal behaviour not exercised by the happy-path window test. +public class ThrottleFirstObservableTests +{ + /// Message attached to synthetic source errors. + private const string SourceErrorMessage = "source error"; + + /// Throttle window for the tests. + private static readonly TimeSpan ThrottleFirstWindow = TimeSpan.FromMilliseconds(50); + + /// Verifies that ThrottleFirst forwards source errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleFirstSourceErrors_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.ThrottleFirst(ThrottleFirstWindow) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that ThrottleFirst forwards completion and ignores post-completion emissions. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleFirstSourceCompletes_ThenForwardsCompletionAndIgnoresLater() + { + const int Initial = 1; + const int IgnoredAfterCompletion = 2; + var subject = new Subject(); + var results = new List(); + var completed = false; + + using var sub = subject.ThrottleFirst(ThrottleFirstWindow) + .Subscribe(results.Add, () => completed = true); + + subject.OnNext(Initial); + subject.OnCompleted(); + subject.OnNext(IgnoredAfterCompletion); + + await Assert.That(completed).IsTrue(); + await Assert.That(results).IsCollectionEqualTo([Initial]); + } + + /// Verifies that values arriving after an error are not delivered downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleFirstValueAfterError_ThenIgnored() + { + const int IgnoredAfterError = 5; + var subject = new Subject(); + var results = new List(); + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.ThrottleFirst(ThrottleFirstWindow) + .Subscribe(results.Add, static _ => { }); + + subject.OnError(expected); + subject.OnNext(IgnoredAfterError); + + 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.Primitives.Extensions.Tests/Operators/ThrottleObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleObservableTests.cs new file mode 100644 index 0000000..92d0d1b --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.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 ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.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 VirtualClock(); + 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 VirtualClock(); + 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 VirtualClock(); + 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.Primitives.Extensions.Tests/Operators/ThrottleUntilTrueObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleUntilTrueObservableTests.cs new file mode 100644 index 0000000..ef1044b --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleUntilTrueObservableTests.cs @@ -0,0 +1,164 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Edge-case coverage for ThrottleUntilTrue backed by +/// ThrottleUntilTrueObservable<T> — predicate-true bypass, predicate-false +/// throttling, error forwarding, completion forwarding, and dispose-before-fire. +public class ThrottleUntilTrueObservableTests +{ + /// Synthetic error message attached to source errors. + private const string SourceErrorMessage = "source error"; + + /// Throttle window in milliseconds for tests. + private const int ThrottleWindowMilliseconds = 50; + + /// Long throttle window in milliseconds used by the dispose-before-fire test. + private const int LongThrottleWindowMilliseconds = 500; + + /// Settle delay in milliseconds used to confirm a throttled emission never fires. + private const int SettleDelayMilliseconds = 150; + + /// Throttle window for tests. + private static readonly TimeSpan ThrottleWindow = TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds); + + /// Verifies that elements matching the predicate emit immediately. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleUntilTruePredicateTrue_ThenEmitsImmediately() + { + const int MatchingValue = 1; + var subject = new Subject(); + var emitted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = subject.ThrottleUntilTrue(ThrottleWindow, static x => x == MatchingValue) + .Subscribe(v => emitted.TrySetResult(v)); + + subject.OnNext(MatchingValue); + + var got = await emitted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(got).IsEqualTo(MatchingValue); + } + + /// Verifies that non-matching elements are throttled but eventually emit. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleUntilTruePredicateFalse_ThenEmitsAfterDelay() + { + const int NonMatchingValue = 99; + var subject = new Subject(); + var emitted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = subject.ThrottleUntilTrue(ThrottleWindow, static _ => false) + .Subscribe(v => emitted.TrySetResult(v)); + + subject.OnNext(NonMatchingValue); + + var got = await emitted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(got).IsEqualTo(NonMatchingValue); + } + + /// Verifies that a later throttled value replaces an earlier still-pending one. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleUntilTrueFastReplacements_ThenLatestWins() + { + const int Earlier = 1; + const int Later = 2; + var subject = new Subject(); + var emitted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = subject.ThrottleUntilTrue(ThrottleWindow, static _ => false) + .Subscribe(v => emitted.TrySetResult(v)); + + subject.OnNext(Earlier); + subject.OnNext(Later); + + var got = await emitted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(got).IsEqualTo(Later); + } + + /// Verifies that source errors are forwarded. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleUntilTrueSourceErrors_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = subject.ThrottleUntilTrue(ThrottleWindow, static _ => true) + .Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that source completion is forwarded and post-completion values ignored. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleUntilTrueSourceCompletes_ThenForwardsCompletion() + { + const int IgnoredAfterCompletion = 9; + var subject = new Subject(); + var completed = false; + + using var sub = subject.ThrottleUntilTrue(ThrottleWindow, static _ => true) + .Subscribe(static _ => { }, () => completed = true); + + subject.OnCompleted(); + subject.OnNext(IgnoredAfterCompletion); + + await Assert.That(completed).IsTrue(); + } + + /// Verifies that disposing before a throttled emission fires suppresses it. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleUntilTrueDisposedBeforeFire_ThenNoEmission() + { + const int NonMatchingValue = 1; + var subject = new Subject(); + var results = new List(); + + var sub = subject.ThrottleUntilTrue(TimeSpan.FromMilliseconds(LongThrottleWindowMilliseconds), static _ => false) + .Subscribe(results.Add); + + subject.OnNext(NonMatchingValue); + sub.Dispose(); + + // Wait past the throttle window to confirm nothing fires. + await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + + 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.Primitives.Extensions.Tests/Operators/UsingActionObservableTests.SecondaryDispose.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingActionObservableTests.SecondaryDispose.cs new file mode 100644 index 0000000..7f9ef3b --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingActionObservableTests.SecondaryDispose.cs @@ -0,0 +1,79 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.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, TaskPoolSequencer.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.Primitives.Extensions.Tests/Operators/UsingActionObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingActionObservableTests.cs new file mode 100644 index 0000000..392a835 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingActionObservableTests.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 ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.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 partial class UsingActionObservableTests +{ + /// Synthetic error message attached to action failures. + private const string ActionFailedMessage = "action failed"; + + /// Verifies that Using with a null action still emits, completes, + /// and disposes the resource. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenUsingNullAction_ThenEmitsUnitCompletesAndDisposes() + { + var resource = new TrackedDisposable(); + var completed = false; + var emitted = 0; + + using var sub = resource.Using(action: null) + .Subscribe(_ => emitted++, () => completed = true); + + await Assert.That(emitted).IsEqualTo(1); + await Assert.That(completed).IsTrue(); + await Assert.That(resource.DisposeCount).IsEqualTo(1); + } + + /// Verifies that Using invokes the action against the resource + /// and disposes after completion. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenUsingActionInvoked_ThenActionRunsThenResourceDisposed() + { + var resource = new TrackedDisposable(); + var actionRan = false; + var completed = false; + + using var sub = resource.Using(r => + { + actionRan = true; + ThrowIfDisposed(r); + }).Subscribe(static _ => { }, () => completed = true); + + await Assert.That(actionRan).IsTrue(); + await Assert.That(completed).IsTrue(); + await Assert.That(resource.DisposeCount).IsEqualTo(1); + } + + /// Verifies that an action exception forwards the error and still disposes the resource. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenUsingActionThrows_ThenForwardsErrorAndDisposes() + { + var resource = new TrackedDisposable(); + Exception? caught = null; + var expected = new InvalidOperationException(ActionFailedMessage); + + using var sub = resource.Using(_ => throw expected) + .Subscribe(static _ => { }, ex => caught = ex); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(resource.DisposeCount).IsEqualTo(1); + } + + /// Verifies that the scheduler overload dispatches via the scheduler and still + /// invokes the action then disposes the resource. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenUsingWithScheduler_ThenRunsViaScheduler() + { + var resource = new TrackedDisposable(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var actionRan = false; + + using var sub = resource.Using(_ => actionRan = true, scheduler: TaskPoolSequencer.Default) + .Subscribe(static _ => { }, () => completed.TrySetResult(true)); + + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(actionRan).IsTrue(); + await Assert.That(resource.DisposeCount).IsEqualTo(1); + } + + /// Throws when the resource was disposed before the action ran. + /// The resource to check. + private static void ThrowIfDisposed(TrackedDisposable resource) + { + if (resource.DisposeCount == 0) + { + return; + } + + throw new InvalidOperationException("disposed before action ran"); + } + + /// Disposable that counts how many times it has been disposed. + private sealed class TrackedDisposable : 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.Primitives.Extensions.Tests/Operators/UsingAndSwitchIfEmptyEdgeTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingAndSwitchIfEmptyEdgeTests.cs new file mode 100644 index 0000000..e0884c2 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingAndSwitchIfEmptyEdgeTests.cs @@ -0,0 +1,187 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Linq; +using System.Reactive.Subjects; +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Edge-case tests for Using (action / func overloads) and +/// SwitchIfEmpty that target paths not exercised by the existing happy-path tests. +public class UsingAndSwitchIfEmptyEdgeTests +{ + /// Sentinel value the fallback observable would emit if it were subscribed. + private const int FallbackSentinel = 99; + + /// Verifies that Using with a null action still emits RxVoid, completes, and disposes the resource. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenUsingActionNull_ThenEmitsUnitAndDisposesResource() + { + var resource = new TrackedDisposable(); + var results = new List(); + var completed = false; + + using var sub = resource.Using(null).Subscribe(results.Add, () => completed = true); + + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(completed).IsTrue(); + await Assert.That(resource.DisposeCount).IsEqualTo(1); + } + + /// Verifies that when the action throws the error is forwarded and the resource is still disposed exactly once. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenUsingActionThrows_ThenForwardsErrorAndDisposes() + { + var resource = new TrackedDisposable(); + Exception? caught = null; + var expected = new InvalidOperationException("action failed"); + + using var sub = resource.Using(_ => throw expected).Subscribe( + static _ => { }, + ex => caught = ex); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(resource.DisposeCount).IsEqualTo(1); + } + + /// Verifies that the scheduler overload defers execution to the scheduler. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenUsingActionWithScheduler_ThenRunsOnScheduler() + { + var resource = new TrackedDisposable(); + var ran = false; + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = resource.Using(_ => ran = true, Sequencer.Default) + .Subscribe( + static _ => { }, + () => completed.TrySetResult()); + + 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); + } + + /// Verifies that the Func overload returns the function's result and disposes the resource. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenUsingFunc_ThenEmitsFunctionResultAndDisposes() + { + const int Expected = 42; + var resource = new TrackedDisposable(); + var results = new List(); + + using var sub = resource.Using(_ => Expected).Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo([Expected]); + await Assert.That(resource.DisposeCount).IsEqualTo(1); + } + + /// Verifies that when the func throws the error is forwarded and the resource is still disposed. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenUsingFuncThrows_ThenForwardsErrorAndDisposes() + { + var resource = new TrackedDisposable(); + Exception? caught = null; + var expected = new InvalidOperationException("func failed"); + + using var sub = resource.Using(_ => throw expected) + .Subscribe(static _ => { }, ex => caught = ex); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(resource.DisposeCount).IsEqualTo(1); + } + + /// Verifies that SwitchIfEmpty forwards source errors without subscribing to the fallback. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSwitchIfEmptySourceErrors_ThenForwardsErrorAndIgnoresFallback() + { + var source = new Subject(); + var fallbackSubscribed = false; + var fallback = Observable.Defer(() => + { + fallbackSubscribed = true; + return Observable.Return(FallbackSentinel); + }); + Exception? caught = null; + var expected = new InvalidOperationException("source error"); + + using var sub = source.SwitchIfEmpty(fallback).Subscribe(static _ => { }, ex => caught = ex); + + source.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(fallbackSubscribed).IsFalse(); + } + + /// Verifies that SwitchIfEmpty completes immediately when the source emits at least one value + /// without subscribing to the fallback. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSwitchIfEmptySourceEmits_ThenCompletesWithoutFallback() + { + const int Value = 5; + var source = new Subject(); + var fallbackSubscribed = false; + var fallback = Observable.Defer(() => + { + fallbackSubscribed = true; + return Observable.Return(FallbackSentinel); + }); + var results = new List(); + var completed = false; + + using var sub = source.SwitchIfEmpty(fallback).Subscribe(results.Add, () => completed = true); + + source.OnNext(Value); + source.OnCompleted(); + + await Assert.That(results).IsCollectionEqualTo([Value]); + await Assert.That(completed).IsTrue(); + await Assert.That(fallbackSubscribed).IsFalse(); + } + + /// Verifies that disposing a SwitchIfEmpty subscription before the source completes stops further emissions. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSwitchIfEmptyDisposed_ThenNoFurtherEmissions() + { + var source = new Subject(); + var fallback = new Subject(); + var results = new List(); + + var sub = source.SwitchIfEmpty(fallback).Subscribe(results.Add); + + sub.Dispose(); + source.OnCompleted(); + fallback.OnNext(FallbackSentinel); + + await Assert.That(results).IsEmpty(); + } + + /// Tracks dispose invocations to verify resource lifecycle. + private sealed class TrackedDisposable : 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.Primitives.Extensions.Tests/Operators/UsingFuncObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingFuncObservableTests.cs new file mode 100644 index 0000000..02f980b --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingFuncObservableTests.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.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.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), TaskPoolSequencer.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.Primitives.Extensions.Tests/Operators/WaitUntilObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/WaitUntilObservableTests.cs new file mode 100644 index 0000000..34f0c5f --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.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.Primitives.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.Primitives.Extensions.Tests/Operators/WhileObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/WhileObservableTests.cs new file mode 100644 index 0000000..ff09ce9 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/WhileObservableTests.cs @@ -0,0 +1,128 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Edge-case coverage for the While operator backed by +/// WhileObservable — inline iteration, scheduler dispatch, predicate-throws, +/// action-throws, and dispose-during-iteration paths. +public class WhileObservableTests +{ + /// Synthetic error message attached to predicate failures. + private const string PredicateFailedMessage = "predicate failed"; + + /// Synthetic error message attached to action failures. + private const string ActionFailedMessage = "action failed"; + + /// Number of inline iterations to run. + private const int IterationCount = 3; + + /// Settle delay in milliseconds used to confirm a disposed loop stops ticking. + private const int SettleDelayMilliseconds = 50; + + /// Maximum tolerated extra iterations after Dispose() returns. + private const int MaxStragglerIterations = 10; + + /// Verifies that the inline form runs until the predicate returns false. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhileInline_ThenRunsUntilPredicateFalseAndCompletes() + { + var remaining = IterationCount; + var emitted = 0; + var completed = false; + + using var sub = ReactiveExtensions.While(() => remaining > 0, () => remaining--) + .Subscribe(_ => emitted++, () => completed = true); + + await Assert.That(emitted).IsEqualTo(IterationCount); + await Assert.That(completed).IsTrue(); + await Assert.That(remaining).IsEqualTo(0); + } + + /// Verifies that the scheduler form dispatches every iteration via the scheduler. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhileWithScheduler_ThenRunsUntilPredicateFalse() + { + var remaining = IterationCount; + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var emitted = 0; + + using var sub = ReactiveExtensions.While(() => remaining > 0, () => remaining--, TaskPoolSequencer.Default) + .Subscribe(_ => emitted++, () => completed.TrySetResult(emitted)); + + var final = await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(final).IsEqualTo(IterationCount); + } + + /// Verifies that an exception thrown by the predicate is forwarded. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhilePredicateThrows_ThenForwardsError() + { + Exception? caught = null; + var expected = new InvalidOperationException(PredicateFailedMessage); + + using var sub = ReactiveExtensions.While(() => throw expected, static () => { }) + .Subscribe(static _ => { }, ex => caught = ex); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that an exception thrown by the action is forwarded. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhileActionThrows_ThenForwardsError() + { + Exception? caught = null; + var expected = new InvalidOperationException(ActionFailedMessage); + + using var sub = ReactiveExtensions.While(static () => true, () => throw expected) + .Subscribe(static _ => { }, ex => caught = ex); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies that disposing the scheduled loop stops further iterations. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhileScheduledThenDisposed_ThenIterationStops() + { + var ran = 0; + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var sub = ReactiveExtensions.While( + static () => true, + () => SignalFirstIteration(ref ran, gate), + TaskPoolSequencer.Default) + .Subscribe(static _ => { }); + + await gate.Task.WaitAsync(TimeSpan.FromSeconds(5)); + sub.Dispose(); + + var snapshot = Volatile.Read(ref ran); + await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + var later = Volatile.Read(ref ran); + + // The loop may execute a few more iterations between Dispose() being called + // and the next disposal-check, but it must not keep ticking forever. + await Assert.That(later - snapshot).IsLessThanOrEqualTo(MaxStragglerIterations); + } + + /// Increments and signals on the first iteration. + /// Shared iteration counter. + /// Completion source signalled after the first iteration. + private static void SignalFirstIteration(ref int counter, TaskCompletionSource gate) + { + if (Interlocked.Increment(ref counter) != 1) + { + return; + } + + gate.TrySetResult(true); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsPortedTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsPortedTests.cs new file mode 100644 index 0000000..ad70f3e --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsPortedTests.cs @@ -0,0 +1,304 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Linq; +using System.Reactive.Subjects; +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// +/// Ported coverage for the migrated synchronous extension operators using primitives runtime types. +/// +public sealed class ReactiveExtensionsPortedTests +{ + /// Candidate keys probed by the first-match test. + private static readonly int[] MatchCandidates = [1, 2, 3]; + + /// Verifies signal data wrappers keep their update/signal semantics. + /// A representing the asynchronous test operation. + [Test] + public async Task DataWrappersExposeUpdateAndSignalState() + { + const int HeartbeatUpdate = 42; + const int StaleUpdate = 7; + + var heartbeat = new Heartbeat(); + var heartbeatUpdate = new Heartbeat(HeartbeatUpdate); + var stale = new Stale(); + var staleUpdate = new Stale(StaleUpdate); + + await Assert.That(heartbeat.IsHeartbeat).IsTrue(); + await Assert.That(heartbeatUpdate.Update).IsEqualTo(HeartbeatUpdate); + await Assert.That(stale.IsStale).IsTrue(); + await Assert.That(staleUpdate.Update).IsEqualTo(StaleUpdate); + Assert.Throws(() => _ = stale.Update); + } + + /// Verifies null filtering, signal projection, and boolean helpers. + /// A representing the asynchronous test operation. + [Test] + public async Task BasicProjectionAndBooleanOperatorsEmitExpectedValues() + { + var nullable = new Subject(); + var notNull = new List(); + using var nonNullSub = nullable.WhereIsNotNull().Subscribe(value => notNull.Add(value!)); + nullable.OnNext(null); + nullable.OnNext("value"); + + var signalValues = new List(); + using var signalSub = Observable.Return(1).AsSignal().Subscribe(signalValues.Add); + + var bools = new Subject(); + var notValues = new List(); + var trueValues = new List(); + var falseValues = new List(); + using var notSub = bools.Not().Subscribe(notValues.Add); + using var trueSub = bools.WhereTrue().Subscribe(trueValues.Add); + using var falseSub = bools.WhereFalse().Subscribe(falseValues.Add); + bools.OnNext(true); + bools.OnNext(false); + + await Assert.That(notNull).IsCollectionEqualTo(["value"]); + await Assert.That(signalValues.Count).IsEqualTo(1); + await Assert.That(signalValues[0]).IsEqualTo(RxVoid.Default); + await Assert.That(notValues).IsCollectionEqualTo([false, true]); + await Assert.That(trueValues).IsCollectionEqualTo([true]); + await Assert.That(falseValues).IsCollectionEqualTo([false]); + } + + /// Verifies buffering and combining helpers copied from ReactiveUI.Extensions. + /// A representing the asynchronous test operation. + [Test] + public async Task BufferAndCombineHelpersPreserveLegacyBehavior() + { + const int MaxAInitial = 1; + const int MaxBInitial = 5; + const int MaxAUpdate = 9; + + var chars = new Subject(); + var frames = new List(); + using var frameSub = chars.BufferUntil('[', ']').Subscribe(frames.Add); + foreach (var value in "x[abc]y") + { + chars.OnNext(value); + } + + var first = new BehaviorSubject(false); + var second = new BehaviorSubject(false); + var allFalse = new List(); + using var allFalseSub = new[] { first, second }.CombineLatestValuesAreAllFalse().Subscribe(allFalse.Add); + first.OnNext(true); + + var maxA = new BehaviorSubject(MaxAInitial); + var maxB = new BehaviorSubject(MaxBInitial); + var maxValues = new List(); + using var maxSub = maxA.GetMax(maxB).Subscribe(maxValues.Add); + maxA.OnNext(MaxAUpdate); + + await Assert.That(frames).IsCollectionEqualTo(["[abc]"]); + await Assert.That(allFalse).IsCollectionEqualTo([true, false]); + await Assert.That(maxValues).IsCollectionEqualTo([MaxBInitial, MaxAUpdate]); + } + + /// Verifies virtual-time operators use instead of Rx schedulers. + /// A representing the asynchronous test operation. + [Test] + public async Task TimeBasedOperatorsUsePrimitiveSequencer() + { + const int FirstValue = 1; + const int SecondValue = 2; + const int StaleWindowSeconds = 2; + const int ExpectedStaleCount = 3; + const int ThirdEmissionIndex = 2; + + var clock = new VirtualClock(); + var source = new Subject(); + var batches = new List>(); + var stale = new List>(); + using var bufferSub = source.BufferUntilInactive(TimeSpan.FromSeconds(1), clock).Subscribe(batches.Add); + using var staleSub = source.DetectStale(TimeSpan.FromSeconds(StaleWindowSeconds), clock).Subscribe(stale.Add); + + source.OnNext(FirstValue); + source.OnNext(SecondValue); + clock.AdvanceBy(TimeSpan.FromSeconds(1)); + clock.AdvanceBy(TimeSpan.FromSeconds(1)); + + await Assert.That(batches.Count).IsEqualTo(1); + await Assert.That(batches[0]).IsCollectionEqualTo([FirstValue, SecondValue]); + await Assert.That(stale.Count).IsEqualTo(ExpectedStaleCount); + await Assert.That(stale[0].Update).IsEqualTo(FirstValue); + await Assert.That(stale[1].Update).IsEqualTo(SecondValue); + await Assert.That(stale[ThirdEmissionIndex].IsStale).IsTrue(); + } + + /// Verifies scheduling and throttling helpers use primitive clocks. + /// A representing the asynchronous test operation. + [Test] + public async Task SchedulingOperatorsUsePrimitiveSequencer() + { + const int ScheduledValue = 5; + const int FirstThrottled = 1; + const int SuppressedThrottled = 2; + const int EmittedThrottled = 3; + + var clock = new VirtualClock(); + var scheduled = new List(); + using var scheduledSub = ScheduledValue.Schedule(TimeSpan.FromSeconds(1), clock).Subscribe(scheduled.Add); + + var throttledSource = new Subject(); + var throttled = new List(); + using var throttleSub = throttledSource.ThrottleFirst(TimeSpan.FromSeconds(1), clock).Subscribe(throttled.Add); + throttledSource.OnNext(FirstThrottled); + throttledSource.OnNext(SuppressedThrottled); + clock.AdvanceBy(TimeSpan.FromSeconds(1)); + throttledSource.OnNext(EmittedThrottled); + clock.AdvanceBy(TimeSpan.FromSeconds(1)); + + await Assert.That(scheduled).IsCollectionEqualTo([ScheduledValue]); + await Assert.That(throttled).IsCollectionEqualTo([FirstThrottled, EmittedThrottled]); + } + + /// Verifies fused projection/filter helpers. + /// A representing the asynchronous test operation. + [Test] + public async Task FusedProjectionOperatorsEmitExpectedValues() + { + const int EvenDivisor = 2; + const int OddInput = 1; + const int EvenInput = 2; + + var values = new Subject(); + var whereSelect = new List(); + var trySelect = new List(); + var constants = new List(); + + using var whereSelectSub = values.WhereSelect(x => x % EvenDivisor == 0, x => $"even-{x}").Subscribe(whereSelect.Add); + using var trySelectSub = values.TrySelect(x => x > OddInput ? $"value-{x}" : null).Subscribe(trySelect.Add); + using var constantsSub = values.SelectConstant("tick").Subscribe(constants.Add); + values.OnNext(OddInput); + values.OnNext(EvenInput); + + await Assert.That(whereSelect).IsCollectionEqualTo(["even-2"]); + await Assert.That(trySelect).IsCollectionEqualTo(["value-2"]); + await Assert.That(constants).IsCollectionEqualTo(["tick", "tick"]); + } + + /// Verifies catch/fallback operators preserve their terminal behavior. + /// A representing the asynchronous test operation. + [Test] + public async Task ErrorFallbackOperatorsHandleFailures() + { + const int CatchReturnValue = 7; + + var catchReturn = new List(); + var catchIgnoreCompleted = false; + Exception? caught = null; + + using var returnSub = Observable.Throw(new InvalidOperationException()) + .CatchReturn(CatchReturnValue) + .Subscribe(catchReturn.Add); + + using var ignoreSub = Observable.Throw(new InvalidOperationException("handled")) + .CatchIgnore(ex => caught = ex) + .Subscribe(static _ => { }, () => catchIgnoreCompleted = true); + + await Assert.That(catchReturn).IsCollectionEqualTo([CatchReturnValue]); + await Assert.That(caught).IsNotNull(); + await Assert.That(catchIgnoreCompleted).IsTrue(); + } + + /// Verifies latest, replay, pairwise, partition, sample, and switch helpers. + /// A representing the asynchronous test operation. + [Test] + public async Task StateAndRoutingOperatorsEmitExpectedValues() + { + const int DefaultValue = -1; + const int PartitionDivisor = 2; + const int FirstValue = 1; + const int SecondValue = 2; + const int FallbackValue = 99; + + var source = new Subject(); + var latest = new List(); + var pairwise = new List<(int Previous, int Current)>(); + var even = new List(); + var odd = new List(); + var sampled = new List(); + var trigger = new Subject(); + + using var latestSub = source.LatestOrDefault(DefaultValue).Subscribe(latest.Add); + using var pairwiseSub = source.Pairwise().Subscribe(pairwise.Add); + var (truePartition, falsePartition) = source.Partition(x => x % PartitionDivisor == 0); + using var evenSub = truePartition.Subscribe(even.Add); + using var oddSub = falsePartition.Subscribe(odd.Add); + using var sampledSub = source.SampleLatest(trigger).Subscribe(sampled.Add); + + source.OnNext(FirstValue); + trigger.OnNext(new object()); + source.OnNext(SecondValue); + trigger.OnNext(new object()); + + var switched = new List(); + using var switchSub = Observable.Empty().SwitchIfEmpty(Observable.Return(FallbackValue)).Subscribe(switched.Add); + + await Assert.That(latest).IsCollectionEqualTo([DefaultValue, FirstValue, SecondValue]); + await Assert.That(pairwise).IsCollectionEqualTo([(FirstValue, SecondValue)]); + await Assert.That(even).IsCollectionEqualTo([SecondValue]); + await Assert.That(odd).IsCollectionEqualTo([FirstValue]); + await Assert.That(sampled).IsCollectionEqualTo([FirstValue, SecondValue]); + await Assert.That(switched).IsCollectionEqualTo([FallbackValue]); + } + + /// Verifies async projection and sequential run helpers. + /// A representing the asynchronous test operation. + [Test] + public async Task AsyncAndSequentialHelpersEmitExpectedValues() + { + const int InputValue = 2; + const int SequentialMultiplier = 2; + const int ConcurrentMultiplier = 3; + const int MaxConcurrency = 2; + const int DelayMilliseconds = 50; + const int SequentialResult = 4; + const int ConcurrentResult = 6; + + var source = new Subject(); + var sequential = new List(); + var concurrent = new List(); + using var seqSub = source.SelectAsyncSequential(x => Task.FromResult(x * SequentialMultiplier)).Subscribe(sequential.Add); + using var conSub = source.SelectAsyncConcurrent(x => Task.FromResult(x * ConcurrentMultiplier), maxConcurrency: MaxConcurrency).Subscribe(concurrent.Add); + + source.OnNext(InputValue); + await Task.Delay(DelayMilliseconds); + + var runAll = new List(); + using var runAllSub = new[] + { + Observable.Return(RxVoid.Default), + Observable.Return(RxVoid.Default) + }.RunAll().Subscribe(runAll.Add); + + await Assert.That(sequential).IsCollectionEqualTo([SequentialResult]); + await Assert.That(concurrent).IsCollectionEqualTo([ConcurrentResult]); + await Assert.That(runAll).IsCollectionEqualTo([RxVoid.Default]); + } + + /// Verifies candidate probing emits the first transformed match. + /// A representing the asynchronous test operation. + [Test] + public async Task FirstMatchFromCandidatesEmitsFirstMatch() + { + var results = new List(); + using var sub = MatchCandidates + .FirstMatchFromCandidates( + key => Observable.Return(key), + value => $"value-{value}", + value => value.EndsWith("2", StringComparison.Ordinal), + "fallback") + .Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo(["value-2"]); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Buffer.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Buffer.cs new file mode 100644 index 0000000..ebfdedf --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Buffer.cs @@ -0,0 +1,355 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// Tests for ReactiveExtensionsTests. +public partial class ReactiveExtensionsTests +{ + /// + /// Tests BufferUntil with character delimiters. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task BufferUntil_WithStartAndEndChars_BuffersCorrectly() + { + using var subject = new Subject(); + var results = new List(); + using var sub = subject.BufferUntil('<', '>').Subscribe(results.Add); + + subject.OnNext('a'); + subject.OnNext('<'); + subject.OnNext('t'); + subject.OnNext('e'); + subject.OnNext('s'); + subject.OnNext('t'); + subject.OnNext('>'); + subject.OnNext('b'); + subject.OnNext('<'); + subject.OnNext('d'); + subject.OnNext('a'); + subject.OnNext('t'); + subject.OnNext('a'); + subject.OnNext('>'); + subject.OnCompleted(); + + using (Assert.Multiple()) + { + await Assert.That(results).Count().IsEqualTo(SampleValue2); + await Assert.That(results[0]).IsEqualTo(""); + await Assert.That(results[1]).IsEqualTo(""); + } + } + + /// Exercises BufferUntilObservable's OnError forwarding — + /// the observer hands the source's error straight to the downstream subscriber. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenBufferUntilSourceErrors_ThenForwardsError() + { + using var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException("buffer-until-error"); + + using var sub = subject.BufferUntil('<', '>').Subscribe(static _ => { }, ex => caught = ex); + + subject.OnNext('a'); + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// + /// Tests BufferUntil emits remaining buffered content when the source completes before the end delimiter. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task BufferUntil_WhenSourceCompletesWithPartialBuffer_EmitsRemainingContent() + { + using var subject = new Subject(); + var results = new List(); + var completed = false; + using var sub = subject.BufferUntil('<', '>').Subscribe(results.Add, () => completed = true); + + subject.OnNext('x'); + subject.OnNext('<'); + subject.OnNext('a'); + subject.OnNext('b'); + subject.OnCompleted(); + + using (Assert.Multiple()) + { + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(results[0]).IsEqualTo(" + /// Tests Conflate with minimum update period. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Conflate_WithMinimumPeriod_DelaysUpdates() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + using var sub = subject.Conflate(TimeSpan.FromTicks(100), scheduler).Subscribe(results.Add); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + subject.OnNext(SampleValue3); + scheduler.AdvanceBy(SchedulerWindowTicks + 1); + + using (Assert.Multiple()) + { + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(results[0]).IsEqualTo(SampleValue3); + } + } + + /// + /// Tests Conflate completes after a pending delayed update has been emitted. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Conflate_WithPendingDelayedUpdate_CompletesAfterFlush() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + var completed = false; + using var sub = subject.Conflate(TimeSpan.FromTicks(100), scheduler) + .Subscribe(results.Add, () => completed = true); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + subject.OnCompleted(); + + await Assert.That(completed).IsFalse(); + + scheduler.AdvanceBy(SchedulerWindowTicks + 1); + + using (Assert.Multiple()) + { + await Assert.That(results).IsCollectionEqualTo([SampleValue2]); + await Assert.That(completed).IsTrue(); + } + } + + /// + /// Tests BufferUntilIdle buffers values until idle period. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task BufferUntilIdle_BuffersUntilIdle() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List>(); + + subject.BufferUntilIdle(TimeSpan.FromMilliseconds(100), scheduler) + .Subscribe(results.Add); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + scheduler.AdvanceBy(TimeSpan.FromMilliseconds(50).Ticks); + subject.OnNext(SampleValue3); + scheduler.AdvanceBy(TimeSpan.FromMilliseconds(150).Ticks); // Wait for idle period + + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(results[0]).IsCollectionEqualTo([1, SampleValue2, SampleValue3]); + } + + /// + /// Tests BufferUntilIdle with scheduler forwards source error after flushing buffered items. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenBufferUntilIdleWithSchedulerSourceErrors_ThenFlushesAndForwardsError() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List>(); + Exception? observedError = null; + + subject.BufferUntilIdle(TimeSpan.FromTicks(100), scheduler) + .Subscribe(results.Add, ex => observedError = ex); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + subject.OnError(new InvalidOperationException("test error")); + + using (Assert.Multiple()) + { + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(results[0]).IsCollectionEqualTo([1, SampleValue2]); + await Assert.That(observedError).IsNotNull(); + } + } + + /// + /// Tests BufferUntilIdle with scheduler flushes buffered items on source completion. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenBufferUntilIdleWithSchedulerSourceCompletes_ThenFlushesAndCompletes() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List>(); + var completed = false; + + subject.BufferUntilIdle(TimeSpan.FromTicks(100), scheduler) + .Subscribe(results.Add, () => completed = true); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + subject.OnCompleted(); + + using (Assert.Multiple()) + { + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(results[0]).IsCollectionEqualTo([1, SampleValue2]); + await Assert.That(completed).IsTrue(); + } + } + + /// + /// Tests BufferUntilIdle without scheduler uses the Publish+Buffer+Throttle path. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenBufferUntilIdleCalledWithoutScheduler_ThenBuffersUntilIdle() + { + var subject = new Subject(); + var results = new List>(); + + subject.BufferUntilIdle(TimeSpan.FromMilliseconds(100)) + .Subscribe(results.Add); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + subject.OnNext(SampleValue3); + subject.OnCompleted(); + + await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); + } + + /// + /// Tests BufferUntilInactive buffers items and flushes after inactivity period. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenBufferUntilInactive_ThenBuffersAndFlushesOnInactivity() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List>(); + + subject.BufferUntilInactive(TimeSpan.FromTicks(100), scheduler) + .Subscribe(results.Add); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + scheduler.AdvanceBy(SchedulerHalfWindowTicks); + subject.OnNext(SampleValue3); + scheduler.AdvanceBy(SchedulerAdvancePastWindowTicks); + + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(results[0]).IsCollectionEqualTo([1, SampleValue2, SampleValue3]); + } + + /// + /// Tests BufferUntilInactive flushes remaining items on error. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenBufferUntilInactiveSourceErrors_ThenFlushesAndForwardsError() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List>(); + Exception? observedError = null; + + subject.BufferUntilInactive(TimeSpan.FromTicks(100), scheduler) + .Subscribe(results.Add, ex => observedError = ex); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + subject.OnError(new InvalidOperationException("test")); + + using (Assert.Multiple()) + { + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(results[0]).IsCollectionEqualTo([1, SampleValue2]); + await Assert.That(observedError).IsNotNull(); + } + } + + /// + /// Tests BufferUntilInactive flushes remaining items on completion. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenBufferUntilInactiveSourceCompletes_ThenFlushesAndCompletes() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List>(); + var completed = false; + + subject.BufferUntilInactive(TimeSpan.FromTicks(100), scheduler) + .Subscribe(results.Add, () => completed = true); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + subject.OnCompleted(); + + using (Assert.Multiple()) + { + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(results[0]).IsCollectionEqualTo([1, SampleValue2]); + await Assert.That(completed).IsTrue(); + } + } + + /// + /// Tests Conflate non-throttled path emits immediately when update period has passed. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenConflateNonThrottledPath_ThenEmitsImmediately() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + var completed = false; + + subject.Conflate(TimeSpan.FromTicks(100), scheduler) + .Subscribe(results.Add, _ => { }, () => completed = true); + + // Emit first value - goes through non-throttled path (line 353) + subject.OnNext(1); + scheduler.AdvanceBy(1); + + // Advance past the minimum update period + scheduler.AdvanceBy(SettleDelayMilliseconds); + + // Emit second value - also non-throttled since enough time passed + subject.OnNext(SampleValue2); + scheduler.AdvanceBy(1); + + // Complete with no scheduled update pending (line 372) + subject.OnCompleted(); + scheduler.AdvanceBy(1); + + await Assert.That(results).IsCollectionEqualTo([1, SampleValue2]); + await Assert.That(completed).IsTrue(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Catch.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Catch.cs new file mode 100644 index 0000000..d93cef7 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Catch.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 System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// Tests for ReactiveExtensionsTests. +public partial class ReactiveExtensionsTests +{ + /// + /// Tests CatchIgnore without error action. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task CatchIgnore_OnError_ReturnsEmpty() + { + using var subject = new Subject(); + var results = new List(); + var completed = false; + using var sub = subject.CatchIgnore().Subscribe(results.Add, _ => { }, () => completed = true); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + subject.OnError(new InvalidOperationException()); + + using (Assert.Multiple()) + { + await Assert.That(results).IsCollectionEqualTo([1, SampleValue2]); + await Assert.That(completed).IsTrue(); + } + } + + /// + /// Tests CatchIgnore with error action. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task CatchIgnore_WithErrorAction_CallsActionAndReturnsEmpty() + { + using var subject = new Subject(); + var results = new List(); + var errorCaught = false; + var completed = false; + using var sub = subject.CatchIgnore(ex => errorCaught = true) + .Subscribe(results.Add, _ => { }, () => completed = true); + + subject.OnNext(1); + subject.OnError(new InvalidOperationException()); + + using (Assert.Multiple()) + { + await Assert.That(results).IsCollectionEqualTo([1]); + await Assert.That(errorCaught).IsTrue(); + await Assert.That(completed).IsTrue(); + } + } + + /// + /// Tests CatchAndReturn with fallback value. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task CatchAndReturn_OnError_ReturnsFallback() + { + var subject = new Subject(); + var results = new List(); + using var sub = subject.CatchAndReturn(99).Subscribe(results.Add); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + subject.OnError(new InvalidOperationException()); + + await Assert.That(results).IsCollectionEqualTo([1, SampleValue2, SampleValue99]); + } + + /// + /// Tests LogErrors invokes the logger when the source faults. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task LogErrors_WhenSourceErrors_InvokesLogger() + { + Exception? logged = null; + Exception? observed = null; + using var subject = new Subject(); + using var sub = subject.LogErrors(ex => logged = ex) + .Subscribe(_ => { }, ex => observed = ex); + + var exception = new InvalidOperationException("boom"); + subject.OnError(exception); + + using (Assert.Multiple()) + { + await Assert.That(logged).IsSameReferenceAs(exception); + await Assert.That(observed).IsSameReferenceAs(exception); + } + } + + /// + /// Tests CatchAndReturn with factory recovers from a specific exception type. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCatchAndReturnWithFactory_ThenRecoverFromException() + { + var subject = new Subject(); + var results = new List(); + + subject.CatchAndReturn(ex => -1) + .Subscribe(results.Add); + + subject.OnNext(1); + subject.OnError(new InvalidOperationException("boom")); + + await Assert.That(results).IsCollectionEqualTo([1, -1]); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.CombineLatest.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.CombineLatest.cs new file mode 100644 index 0000000..c37e2b6 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.CombineLatest.cs @@ -0,0 +1,229 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// Tests for ReactiveExtensionsTests. +public partial class ReactiveExtensionsTests +{ + /// Exercises the BooleanReduceObservable ctor's sources is null + /// branch — both the cast-to-IReadOnlyList and the sources?.ToList() shortcut produce + /// null, so InvalidOperationExceptionHelper.Check throws. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCombineLatestValuesAreAllFalseNullSources_ThenThrowsInvalidOperation() + { + Action call = () => _ = ((IEnumerable>)null!).CombineLatestValuesAreAllFalse(); + var ex = Assert.Throws(call); + + await Assert.That(ex).IsNotNull(); + } + + /// Exercises the BooleanReduceObservable ctor's fallback when the supplied + /// is not also an — the cast + /// fails, the null-coalescing operator falls through to sources.ToList(). + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCombineLatestValuesAreAllFalseNonListEnumerable_ThenMaterializedToList() + { + var subject1 = new BehaviorSubject(false); + var subject2 = new BehaviorSubject(false); + IEnumerable> sources = new[] { subject1.AsObservable(), subject2.AsObservable() }.Where(static _ => true); + bool? result = null; + + using var sub = sources.CombineLatestValuesAreAllFalse().Subscribe(x => result = x); + + await Assert.That(result).IsTrue(); + } + +/// + /// Tests CombineLatestValuesAreAllFalse. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task CombineLatestValuesAreAllFalse_WhenAllFalse_ReturnsTrue() + { + var subject1 = new BehaviorSubject(false); + var subject2 = new BehaviorSubject(false); + var sources = new[] { subject1.AsObservable(), subject2.AsObservable() }; + bool? result = null; + using var sub = sources.CombineLatestValuesAreAllFalse().Subscribe(x => result = x); + + await Assert.That(result).IsTrue(); + } + + /// + /// Tests CombineLatestValuesAreAllTrue. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task CombineLatestValuesAreAllTrue_WhenAllTrue_ReturnsTrue() + { + var subject1 = new BehaviorSubject(true); + var subject2 = new BehaviorSubject(true); + var sources = new[] { subject1.AsObservable(), subject2.AsObservable() }; + bool? result = null; + using var sub = sources.CombineLatestValuesAreAllTrue().Subscribe(x => result = x); + + await Assert.That(result).IsTrue(); + } + + /// + /// Tests GetMax returns maximum value. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task GetMax_WithMultipleSources_ReturnsMaximum() + { + var subject1 = new BehaviorSubject(5); + var subject2 = new BehaviorSubject(10); + var subject3 = new BehaviorSubject(3); + int? result = null; + using var sub = subject1.GetMax(subject2, subject3).Subscribe(x => result = x); + + await Assert.That(result).IsEqualTo(SampleValue10); + } + + /// + /// Tests GetMin returns minimum value. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task GetMin_WithMultipleSources_ReturnsMinimum() + { + var subject1 = new BehaviorSubject(5); + var subject2 = new BehaviorSubject(10); + var subject3 = new BehaviorSubject(3); + int? result = null; + using var sub = subject1.GetMin(subject2, subject3).Subscribe(x => result = x); + + await Assert.That(result).IsEqualTo(SampleValue3); + } + + /// + /// Tests GetMin tracking minimum values as sources change over time. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task GetMin_TracksMinimumOverTime() + { + var subject1 = new BehaviorSubject(5); + var subject2 = new BehaviorSubject(10); + var subject3 = new BehaviorSubject(3); + + var results = new List(); + using var sub = subject1 + .GetMin(subject2, subject3) + .Subscribe(results.Add); + + // Initial minimum is 3 + await Assert.That(results).IsCollectionEqualTo([SampleValue3]); + + // Change minimum to 1 + subject3.OnNext(1); + await Assert.That(results).IsCollectionEqualTo([SampleValue3, 1]); + + // Change minimum to 0 + subject1.OnNext(0); + await Assert.That(results).IsCollectionEqualTo([SampleValue3, 1, 0]); + } + + /// + /// Tests GetMax tracking maximum values as sources change over time. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task GetMax_TracksMaximumOverTime() + { + var subject1 = new BehaviorSubject(5); + var subject2 = new BehaviorSubject(10); + var subject3 = new BehaviorSubject(3); + + var results = new List(); + using var sub = subject1 + .GetMax(subject2, subject3) + .Subscribe(results.Add); + + // Initial maximum is 10 + await Assert.That(results).IsCollectionEqualTo([SampleValue10]); + + // Change maximum to 15 + subject2.OnNext(SampleValue15); + await Assert.That(results).IsCollectionEqualTo([SampleValue10, SampleValue15]); + + // Change maximum to 20 + subject1.OnNext(SampleValue20); + await Assert.That(results).IsCollectionEqualTo([SampleValue10, SampleValue15, SampleValue20]); + } + + /// + /// Tests CombineLatestValuesAreAllTrue tracking state changes. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task CombineLatestValuesAreAllTrue_TracksStateChanges() + { + var subject1 = new BehaviorSubject(false); + var subject2 = new BehaviorSubject(false); + var subject3 = new BehaviorSubject(false); + + var results = new List(); + using var sub = new[] { subject1, subject2, subject3 } + .CombineLatestValuesAreAllTrue() + .Subscribe(results.Add); + + // Initially all false + await Assert.That(results).IsCollectionEqualTo([false]); + + // One true, still false + subject1.OnNext(true); + await Assert.That(results).IsCollectionEqualTo([false, false]); + + // Two true, still false + subject2.OnNext(true); + await Assert.That(results).IsCollectionEqualTo([false, false, false]); + + // All true + subject3.OnNext(true); + await Assert.That(results).IsCollectionEqualTo([false, false, false, true]); + + // Back to false + subject1.OnNext(false); + await Assert.That(results).IsCollectionEqualTo([false, false, false, true, false]); + } + + /// + /// Tests CombineLatestValuesAreAllFalse tracking state changes. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task CombineLatestValuesAreAllFalse_TracksStateChanges() + { + var subject1 = new BehaviorSubject(false); + var subject2 = new BehaviorSubject(false); + var subject3 = new BehaviorSubject(false); + + var results = new List(); + using var sub = new[] { subject1, subject2, subject3 } + .CombineLatestValuesAreAllFalse() + .Subscribe(results.Add); + + // Initially all false - result is true + await Assert.That(results).IsCollectionEqualTo([true]); + + // One becomes true - result becomes false + subject1.OnNext(true); + await Assert.That(results).IsCollectionEqualTo([true, false]); + + // Back to false - result becomes true + subject1.OnNext(false); + await Assert.That(results).IsCollectionEqualTo([true, false, true]); + + // Another becomes true - result becomes false + subject2.OnNext(true); + await Assert.That(results).IsCollectionEqualTo([true, false, true, false]); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.FastForEachAndForEach.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.FastForEachAndForEach.cs new file mode 100644 index 0000000..2a66371 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.FastForEachAndForEach.cs @@ -0,0 +1,236 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive; +using System.Reactive.Linq; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// Tests for ReactiveExtensionsTests. +public partial class ReactiveExtensionsTests +{ + /// + /// Tests ForEach flattens enumerables. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task ForEach_FlattensEnumerables() + { + var source = Observable.Return(ExpectedSequence123); + var results = new List(); + using var sub = source.ForEach().Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo([1, SampleValue2, SampleValue3]); + } + + /// + /// Tests FastForEach with List source emits all items via OnNext. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFastForEachWithList_ThenEmitsAllItems() + { + var source = Observable.Return(new List { 1, 2, 3 } as IEnumerable); + var results = new List(); + using var sub = source.ForEach().Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo([1, SampleValue2, SampleValue3]); + } + + /// + /// Tests FastForEach with array source emits all items via OnNext. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFastForEachWithArray_ThenEmitsAllItems() + { + var arr = new[] { 1, 2, 3 }; + var results = new List(); + using var sub = arr.FromArray().Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo([1, SampleValue2, SampleValue3]); + } + + /// + /// Tests FastForEach with generic IEnumerable source emits all items. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFastForEachWithEnumerable_ThenEmitsAllItems() + { + var source = Observable.Return(Enumerate()); + var results = new List(); + using var sub = source.ForEach().Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo([1, SampleValue2, SampleValue3]); + + static IEnumerable Enumerate() + { + yield return 1; + yield return SampleValue2; + yield return SampleValue3; + } + } + + /// + /// Tests FastForEach with IList branch using ArraySegment which implements IList but not List or T[]. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFastForEachWithIList_ThenEmitsAllItems() + { + IList ilist = new ArraySegment([10, 20, 30]); + var source = Observable.Return((IEnumerable)ilist); + var results = new List(); + using var sub = source.ForEach().Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo([SampleValue10, SampleValue20, SampleValue30]); + } + + /// + /// Tests FastForEach with an array passed through ForEach (Observable of IEnumerable). + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFastForEachWithArrayViaForEach_ThenEmitsAllItems() + { + var arr = new[] { 10, 20, 30 }; + var source = Observable.Return(arr as IEnumerable); + var results = new List(); + using var sub = source.ForEach().Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo([SampleValue10, SampleValue20, SampleValue30]); + } + + /// + /// Tests FastForEach with an IList source. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFastForEach_GivenIList_ThenAllItemsEmitted() + { + // Given + List source = [1, 2, 3]; + var received = new List(); + var observer = Observer.Create(received.Add); + + // When + observer.FastForEach(source); + + // Then + await Assert.That(received).IsCollectionEqualTo([1, SampleValue2, SampleValue3]); + } + + /// + /// Tests FastForEach with an array source. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFastForEach_GivenArray_ThenAllItemsEmitted() + { + // Given + var source = new[] { 10, 20, 30 }; + var received = new List(); + var observer = Observer.Create(received.Add); + + // When + observer.FastForEach(source); + + // Then + await Assert.That(received).IsCollectionEqualTo([SampleValue10, SampleValue20, SampleValue30]); + } + + /// + /// Tests FastForEach with a plain IEnumerable source. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFastForEach_GivenIEnumerable_ThenAllItemsEmitted() + { + // Given + static IEnumerable Generate() + { + yield return SchedulerWindowTicks; + yield return SettleDelayMilliseconds; + } + + var received = new List(); + var observer = Observer.Create(received.Add); + + // When + observer.FastForEach(Generate()); + + // Then + await Assert.That(received).IsCollectionEqualTo([SchedulerWindowTicks, SettleDelayMilliseconds]); + } + + /// + /// Verifies that FastForEach dispatches a T[] array through the dedicated array branch, + /// which is checked before the IList{T} branch. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFastForEachWithArrayAsIEnumerable_ThenHandledByArrayBranch() + { + var arr = new[] { 5, 10, 15 }; + var received = new List(); + var observer = Observer.Create(received.Add); + + observer.FastForEach(arr); + + await Assert.That(received).IsCollectionEqualTo([SampleValue5, SampleValue10, SampleValue15]); + } + + /// + /// Verifies that FastForEach iterates a T[] array correctly when the array contains a + /// single element, exercising the array branch with a minimal collection. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFastForEach_GivenSingleElementArray_ThenEmitsSingleItem() + { + // Given + var source = new[] { 99 }; + var received = new List(); + var observer = Observer.Create(received.Add); + + // When + observer.FastForEach(source); + + // Then + await Assert.That(received).IsCollectionEqualTo([SampleValue99]); + } + + /// Verifies FastForEach handles an that is not an . + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFastForEach_GivenReadOnlyListOnly_ThenAllItemsEmitted() + { + var source = new ReadOnlyListOnly([SampleValue5, SampleValue10, SampleValue15]); + var received = new List(); + var observer = Observer.Create(received.Add); + + observer.FastForEach(source); + + await Assert.That(received).IsCollectionEqualTo([SampleValue5, SampleValue10, SampleValue15]); + } + + /// Read-only list implementation that deliberately does not implement . + /// The item type. + /// The backing items. + private sealed class ReadOnlyListOnly(T[] items) : IReadOnlyList + { + /// + public int Count => items.Length; + + /// + public T this[int index] => items[index]; + + /// + public IEnumerator GetEnumerator() => ((IEnumerable)items).GetEnumerator(); + + /// + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.FusedSelect.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.FusedSelect.cs new file mode 100644 index 0000000..d436b0e --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.FusedSelect.cs @@ -0,0 +1,201 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Linq; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// Tests for the fused Select/Where operators and the candidate-walking helpers +/// (WhereSelect, SelectConstant, TrySelect, SelectManyThen, +/// RunAll, FirstMatchFromCandidates). +public partial class ReactiveExtensionsTests +{ + /// Constant emitted by SelectConstant tests so the chosen value is unambiguous. + private const string SelectConstantSentinel = "constant"; + + /// Fallback value used by FirstMatchFromCandidates when no candidate matches. + private const string CandidateFallback = "fallback"; + + /// + /// Verifies that WhereSelect forwards only values that satisfy the predicate, projected through the selector. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhereSelect_ThenEmitsOnlyMatchingProjectedValues() + { + var results = new List(); + + using var subscription = new[] { 1, SampleValue2, SampleValue3, SampleValue4 } + .ToObservable() + .WhereSelect(static x => x % 2 == 0, static x => $"e{x}") + .Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo(["e2", "e4"]); + } + + /// Verifies that WhereSelect forwards source errors to the downstream observer. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhereSelectSourceErrors_ThenErrorForwarded() + { + var expected = new InvalidOperationException("boom"); + Exception? caught = null; + + using var subscription = Observable.Throw(expected) + .WhereSelect(static x => true, static x => x.ToString()) + .Subscribe(static _ => { }, ex => caught = ex); + + await Assert.That(caught).IsEqualTo(expected); + } + + /// Verifies that SelectConstant emits the constant for every source element regardless of value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectConstant_ThenEmitsConstantForEachElement() + { + var results = new List(); + + using var subscription = new[] { 1, SampleValue2, SampleValue3 } + .ToObservable() + .SelectConstant(SelectConstantSentinel) + .Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo([SelectConstantSentinel, SelectConstantSentinel, SelectConstantSentinel]); + } + + /// Verifies that TrySelect drops null projected values and emits only the non-null results. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTrySelect_ThenDropsNullProjections() + { + var results = new List(); + + using var subscription = new[] { 1, SampleValue2, SampleValue3, SampleValue4 } + .ToObservable() + .TrySelect(static x => x % 2 == 0 ? $"e{x}" : null) + .Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo(["e2", "e4"]); + } + + /// Verifies that SelectManyThen runs both projections in sequence and forwards the final inner result. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectManyThen_ThenChainsTwoProjections() + { + const int ExpectedChained = 11; + var results = new List(); + + // Single source emission keeps the test deterministic — the operator's downstream + // completes once the inner-inner observable completes, so a multi-emission source + // would race against the early-completion semantic. + using var subscription = Observable.Return(1) + .SelectManyThen( + static x => Observable.Return(x * SampleValue10), + static mid => Observable.Return(mid + 1)) + .Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo([ExpectedChained]); + } + + /// Verifies that RunAll completes after every source has completed and emits a single RxVoid. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRunAll_ThenEmitsSingleUnitAfterAllComplete() + { + var results = new List(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + IReadOnlyList> sources = + [ + Observable.Return(RxVoid.Default), + Observable.Return(RxVoid.Default), + Observable.Return(RxVoid.Default), + ]; + + using var subscription = sources.RunAll().Subscribe( + results.Add, + () => completed.TrySetResult()); + + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(results).Count().IsEqualTo(1); + } + + /// + /// Verifies that RunAll on an empty list still emits the terminal and + /// completes — the empty case is vacuously "all sources completed". + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRunAllEmpty_ThenEmitsUnitAndCompletes() + { + var results = new List(); + var completed = false; + + using var subscription = Array.Empty>().RunAll().Subscribe( + results.Add, + () => completed = true); + + await Assert.That(completed).IsTrue(); + await Assert.That(results).Count().IsEqualTo(1); + } + + /// Verifies that FirstMatchFromCandidates emits the first transformed value that satisfies the predicate. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFirstMatchFromCandidates_ThenEmitsFirstSatisfyingValue() + { + var keys = new[] { "a", "b", "c" }; + var results = new List(); + + using var subscription = keys.FirstMatchFromCandidates( + static key => Observable.Return(key + "-raw"), + static raw => raw.ToUpperInvariant(), + static transformed => transformed.StartsWith('B'), + CandidateFallback) + .Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo(["B-RAW"]); + } + + /// Verifies that FirstMatchFromCandidates emits the fallback when no candidate satisfies the predicate. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFirstMatchFromCandidatesNoMatch_ThenEmitsFallback() + { + var keys = new[] { "a", "b", "c" }; + var results = new List(); + + using var subscription = keys.FirstMatchFromCandidates( + static key => Observable.Return(key + "-raw"), + static raw => raw.ToUpperInvariant(), + static _ => false, + CandidateFallback) + .Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo([CandidateFallback]); + } + + /// + /// Verifies that FirstMatchFromCandidates skips candidates whose projection errors and continues walking the list. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFirstMatchFromCandidatesProjectionErrors_ThenSkipsAndContinues() + { + var keys = new[] { "fail", "match" }; + var results = new List(); + + using var subscription = keys.FirstMatchFromCandidates( + static key => key == "fail" + ? Observable.Throw(new InvalidOperationException("ignored")) + : Observable.Return(key + "-raw"), + static raw => raw.ToUpperInvariant(), + static transformed => transformed.StartsWith('M'), + CandidateFallback) + .Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo(["MATCH-RAW"]); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Misc.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Misc.cs new file mode 100644 index 0000000..f809ac9 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Misc.cs @@ -0,0 +1,953 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// Tests for ReactiveExtensionsTests. +public partial class ReactiveExtensionsTests +{ + /// String literal "changed" used by multiple tests. + private const string ChangedValueLiteral = "changed"; + + /// 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"]; + + /// + /// Syncronizes the asynchronous runs with asynchronous tasks in subscriptions. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task SyncronizeAsync_RunsWithAsyncTasksInSubscriptions() + { + // Given, When + var result = 0; + var itterations = 0; + var subject = new Subject(); + var tasks = new List(); + using var disposable = subject + .SynchronizeAsync() + .Subscribe(x => tasks.Add(HandleAsync(x))); + + async Task HandleAsync((bool Value, IDisposable Sync) x) + { + try + { + if (x.Value) + { + await Task.Delay(LongDelayMilliseconds); + Interlocked.Increment(ref result); + } + else + { + await Task.Delay(ShortDelayMilliseconds); + Interlocked.Decrement(ref result); + } + } + finally + { + x.Sync.Dispose(); + Interlocked.Increment(ref itterations); + } + } + + subject.OnNext(true); + subject.OnNext(false); + subject.OnNext(true); + subject.OnNext(false); + subject.OnNext(true); + subject.OnNext(false); + + await Task.WhenAll(tasks); + + while (itterations < SampleValue6) + { + Thread.Yield(); + } + + // Then + await Assert.That(result).IsZero(); + } + + /// + /// Tests OnNext with params. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task OnNext_WithMultipleValues_PushesAll() + { + var results = new List(); + var subject = new Subject(); + using var sub = subject.Subscribe(results.Add); + + subject.OnNext(1, SampleValue2, SampleValue3, SampleValue4, SampleValue5); + + await Assert.That(results).IsCollectionEqualTo([1, SampleValue2, SampleValue3, SampleValue4, SampleValue5]); + } + + /// + /// Tests FromArray with scheduler. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task FromArray_WithScheduler_EmitsElements() + { + var source = new[] { 1, 2, 3, 4, 5 }; + var results = new List(); + using var sub = source.FromArray(Sequencer.Immediate).Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo(source); + } + + /// + /// Tests Filter with regex. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Filter_WithRegex_FiltersStrings() + { + var source = SequenceTest123HelloTest456World.ToObservable(); + var results = new List(); + using var sub = source.Filter(@"^test\d+$").Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo(["test123", "test456"]); + } + + /// + /// Tests Shuffle randomizes array. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Shuffle_RandomizesArray() + { + var original = Enumerable.Range(1, 100).ToArray(); + var source = Observable.Return(original.ToArray()); + int[]? result = null; + using var sub = source.Shuffle().Subscribe(x => result = x); + + using (Assert.Multiple()) + { + await Assert.That(result).IsNotNull(); + await Assert.That(result).Count().IsEqualTo(SchedulerWindowTicks); + var sorted = result!.ToArray(); + Array.Sort(sorted); + await Assert.That(sorted).IsCollectionEqualTo(original); + } + } + + /// + /// Tests TakeUntil with predicate. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task TakeUntil_WithPredicate_CompletesWhenPredicateTrue() + { + var subject = new Subject(); + var results = new List(); + using var sub = subject.TakeUntil(x => x >= 5).Subscribe(results.Add); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + subject.OnNext(SampleValue5); + subject.OnNext(SampleValue6); + + await Assert.That(results).IsCollectionEqualTo([1, SampleValue2, SampleValue5]); + } + + /// + /// Tests Partition splits sequence. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Partition_SplitsSequence() + { + var subject = new Subject(); + var trueResults = new List(); + var falseResults = new List(); + + var (trueObs, falseObs) = subject.Partition(x => x % SampleValue2 == 0); + + using var trueSub = trueObs.Subscribe(trueResults.Add); + using var falseSub = falseObs.Subscribe(falseResults.Add); + + for (int i = 1; i <= SampleValue10; i++) + { + subject.OnNext(i); + } + + subject.OnCompleted(); + + using (Assert.Multiple()) + { + await Assert.That(trueResults).IsCollectionEqualTo([SampleValue2, SampleValue4, SampleValue6, SampleValue8, SampleValue10]); + await Assert.That(falseResults).IsCollectionEqualTo([1, SampleValue3, SampleValue5, SampleValue7, SampleValue9]); + } + } + + /// + /// Tests WaitUntil takes first matching. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WaitUntil_TakesFirstMatching() + { + var subject = new Subject(); + var results = new List(); + using var sub = subject.WaitUntil(x => x > 5).Subscribe(results.Add); + + subject.OnNext(1); + subject.OnNext(SampleValue3); + subject.OnNext(SampleValue7); + subject.OnNext(SampleValue9); + + await Assert.That(results).IsCollectionEqualTo([SampleValue7]); + } + + /// + /// Tests DoOnSubscribe executes on subscribe. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task DoOnSubscribe_ExecutesOnSubscribe() + { + var executed = false; + var source = Observable.Return(1); + using var sub = source.DoOnSubscribe(() => executed = true).Subscribe(); + + await Assert.That(executed).IsTrue(); + } + + /// + /// Tests DoOnDispose executes on dispose. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task DoOnDispose_ExecutesOnDispose() + { + var executed = false; + var source = Observable.Never(); + var sub = source.DoOnDispose(() => executed = true).Subscribe(); + + sub.Dispose(); + + await Assert.That(executed).IsTrue(); + } + + /// + /// Tests Stale class with update. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Stale_WithUpdate_IsNotStale() + { + var stale = new Stale(42); + + using (Assert.Multiple()) + { + await Assert.That(stale.IsStale).IsFalse(); + await Assert.That(stale.Update).IsEqualTo(SampleValue42); + } + } + + /// + /// Tests Stale class without update. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Stale_WithoutUpdate_IsStale() + { + var stale = new Stale(); + + await Assert.That(stale.IsStale).IsTrue(); + } + + /// + /// Tests Stale throws on Update access when stale. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Stale_WithoutUpdate_ThrowsOnUpdateAccess() + { + var stale = new Stale(); + + var ex = Assert.Throws(() => _ = stale.Update); + + await Assert.That(ex).IsNotNull(); + } + + /// + /// Tests Continuation can be disposed. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Continuation_CanBeDisposed() + { + var continuation = new Continuation(); + + continuation.Dispose(); + } + + /// + /// Tests Continuation tracks completed phases. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Continuation_TracksCompletedPhases() + { + using var continuation = new Continuation(); + + var phases = continuation.CompletedPhases; + + await Assert.That(phases).IsGreaterThanOrEqualTo(0); + } + + /// + /// Tests Pairwise emits previous and current pairs. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Pairwise_EmitsPairs() + { + var subject = new Subject(); + var results = new List<(int Previous, int Current)>(); + + subject.Pairwise().Subscribe(results.Add); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + subject.OnNext(SampleValue3); + + using (Assert.Multiple()) + { + await Assert.That(results).Count().IsEqualTo(SampleValue2); + await Assert.That(results[0]).IsEqualTo((1, SampleValue2)); + await Assert.That(results[1]).IsEqualTo((SampleValue2, SampleValue3)); + } + } + + /// + /// Tests ScanWithInitial starts with initial value. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task ScanWithInitial_StartsWithInitial() + { + var subject = new Subject(); + var results = new List(); + + subject.ScanWithInitial(SampleValue10, (acc, x) => acc + x).Subscribe(results.Add); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + + await Assert.That(results).IsCollectionEqualTo([SampleValue10, SampleValue11, SampleValue13]); + } + + /// + /// Tests SampleLatest samples latest on trigger. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task SampleLatest_SamplesLatestOnTrigger() + { + var subject = new Subject(); + var trigger = new Subject(); + var results = new List(); + + subject.SampleLatest(trigger).Subscribe(results.Add); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + trigger.OnNext(new object()); // Should emit 2 + subject.OnNext(SampleValue3); + trigger.OnNext(new object()); // Should emit 3 + + await Assert.That(results).IsCollectionEqualTo([SampleValue2, SampleValue3]); + } + + /// + /// Tests SwitchIfEmpty switches to fallback when empty. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task SwitchIfEmpty_SwitchesWhenEmpty() + { + var emptySubject = new Subject(); + var fallbackSubject = new Subject(); + var results = new List(); + + emptySubject.SwitchIfEmpty(fallbackSubject).Subscribe(results.Add); + + emptySubject.OnCompleted(); // Empty completes + fallbackSubject.OnNext(SampleValue42); + fallbackSubject.OnCompleted(); + + await Assert.That(results).IsCollectionEqualTo([SampleValue42]); + } + + /// + /// Tests ToReadOnlyBehavior creates read-only behavior. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task ToReadOnlyBehavior_CreatesReadOnly() + { + var (observable, observer) = ReactiveExtensions.ToReadOnlyBehavior(SampleValue10); + var results = new List(); + + observable.Subscribe(results.Add); + + observer.OnNext(SampleValue20); + observer.OnNext(SampleValue30); + + await Assert.That(results).IsCollectionEqualTo([SampleValue10, SampleValue20, SampleValue30]); + } + + /// + /// Tests ToHotTask converts to hot task. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task ToHotTask_ConvertsToTask() + { + var subject = new Subject(); + var task = subject.ToHotTask(); + + subject.OnNext(SampleValue42); + + await Assert.That(await task).IsEqualTo(SampleValue42); + } + + /// + /// Tests ToHotValueTask converts to a hot value task that completes with the first value. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task ToHotValueTask_ConvertsToValueTask() + { + var subject = new Subject(); + var task = subject.ToHotValueTask(); + + subject.OnNext(SampleValue42); + + await Assert.That(await task).IsEqualTo(SampleValue42); + } + + /// + /// Tests ToPropertyObservable observes property changes. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task ToPropertyObservable_ObservesProperty() + { + var obj = new TestNotifyPropertyChanged { TestProperty = InitialValueLiteral }; + var results = new List(); + + obj.ToPropertyObservable(x => x.TestProperty).Subscribe(results.Add); + + obj.TestProperty = ChangedValueLiteral; + + await Assert.That(results).IsCollectionEqualTo([InitialValueLiteral, ChangedValueLiteral]); + } + + /// + /// Tests SkipWhileNull emits values after the first non-null value, including later nulls. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task SkipWhileNull_WhenFirstValueArrives_EmitsRemainingValues() + { + IObservable source = Observable.Create(observer => + { + observer.OnNext(null!); + observer.OnNext(null!); + observer.OnNext("first"); + observer.OnNext(null!); + observer.OnNext("second"); + observer.OnCompleted(); + return Disposable.Empty; + }); + var results = new List(); + + using var sub = source.SkipWhileNull().Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo(["first", null, "second"]); + } + + /// + /// Tests ReplayLastOnSubscribe replays last value to new subscribers. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task ReplayLastOnSubscribe_ReplaysLastValueToNewSubscribers() + { + var subject = new Subject(); + var replayed = subject.ReplayLastOnSubscribe(SampleValue99); + + var results1 = new List(); + using var sub1 = replayed.Subscribe(results1.Add); + + // First subscriber gets the per-subscription initial value. + await Assert.That(results1).IsCollectionEqualTo([SampleValue99]); + + subject.OnNext(1); + await Assert.That(results1).IsCollectionEqualTo([SampleValue99, 1]); + + var results2 = new List(); + using var sub2 = replayed.Subscribe(results2.Add); + + // ReplayLastOnSubscribe creates a fresh BehaviorSubject per subscriber seeded with the initial value; + // late subscribers therefore receive the initial value, not values emitted to earlier subscribers. + await Assert.That(results2).IsCollectionEqualTo([SampleValue99]); + + subject.OnNext(SampleValue2); + using (Assert.Multiple()) + { + await Assert.That(results1).IsCollectionEqualTo([SampleValue99, 1, SampleValue2]); + await Assert.That(results2).IsCollectionEqualTo([SampleValue99, SampleValue2]); + } + } + + /// + /// Tests LatestOrDefault emits default first then distinct values. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLatestOrDefault_ThenEmitsDefaultThenDistinctValues() + { + var subject = new Subject(); + var results = new List(); + + subject.LatestOrDefault(SampleValue42).Subscribe(results.Add); + + subject.OnNext(SampleValue42); // Same as default, should be suppressed by DistinctUntilChanged + subject.OnNext(1); + subject.OnNext(1); // Duplicate, suppressed + subject.OnNext(SampleValue2); + + await Assert.That(results).IsCollectionEqualTo([SampleValue42, 1, SampleValue2]); + } + + /// + /// Tests OnNext with null observer throws ArgumentNullException. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnNextWithNullObserver_ThenThrowsArgumentNullException() + { + IObserver? observer = null; + + var ex = Assert.Throws(() => observer!.OnNext(1, 2, 3)); + + await Assert.That(ex).IsNotNull(); + } + + /// + /// Tests OnNext with null events array throws ArgumentNullException. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnNextWithNullEvents_ThenThrowsArgumentNullException() + { + var subject = new Subject(); + + var ex = Assert.Throws(() => subject.OnNext(null!)); + + await Assert.That(ex).IsNotNull(); + } + + /// + /// Tests SubscribeAsync with all three handlers (onNext, onError, onCompleted). + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSubscribeAsyncWithAllHandlers_ThenInvokesAll() + { + var subject = new Subject(); + var results = new List(); + var completed = false; + Exception? caughtError = null; + var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + const int ExpectedCount = 2; + var allReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = subject.SubscribeAsync( + async x => + { + await Task.Yield(); + results.Add(x); + _ = results.Count == ExpectedCount && allReceived.TrySetResult(); + }, + ex => caughtError = ex, + () => + { + completed = true; + completionSource.TrySetResult(true); + }); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + await allReceived.Task.WaitAsync(TimeSpan.FromSeconds(5)); + subject.OnCompleted(); + + await completionSource.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + using (Assert.Multiple()) + { + await Assert.That(results).IsCollectionEqualTo([1, SampleValue2]); + await Assert.That(completed).IsTrue(); + await Assert.That(caughtError).IsNull(); + } + } + + /// + /// Tests ToPropertyObservable unsubscribes from PropertyChanged when disposed, + /// exercising line 1428 of ReactiveExtensions.cs (the -= handler). + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenToPropertyObservableDisposed_ThenUnsubscribesFromPropertyChanged() + { + var obj = new TestNotifyPropertyChanged { TestProperty = InitialValueLiteral }; + var results = new List(); + + var sub = obj.ToPropertyObservable(x => x.TestProperty).Subscribe(results.Add); + + obj.TestProperty = ChangedValueLiteral; + + // Dispose the subscription, triggering the -= handler + sub.Dispose(); + + // Changes after dispose should not be observed + obj.TestProperty = "afterDispose"; + + 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 VirtualClock(); + 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 VirtualClock(); + var ran = false; + + scheduler.ScheduleSafe(TimeSpan.FromTicks(DelayTicks), () => ran = true); + + await Assert.That(ran).IsFalse(); + scheduler.AdvanceBy(DelayTicks + 1); + 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, Sequencer.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([RxVoid.Default]); + await Assert.That(completed).IsTrue(); + } + + /// Exercises CatchReturn's OnCompleted forwarder — when the source + /// completes normally without erroring, completion passes through to the downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCatchReturnSourceCompletesNormally_ThenForwardsCompletion() + { + 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.OnCompleted(); + + await Assert.That(results).IsCollectionEqualTo([1]); + await Assert.That(completed).IsTrue(); + } + + /// Exercises CatchIgnore<T,TException>'s OnCompleted forwarder — + /// when the source completes normally, the observer passes completion straight through. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCatchIgnoreWithErrorActionSourceCompletesNormally_ThenForwardsCompletion() + { + var subject = new Subject(); + var results = new List(); + var completed = false; + + using var sub = subject.CatchIgnore(static _ => { }) + .Subscribe(results.Add, () => completed = true); + + subject.OnNext(1); + subject.OnCompleted(); + + await Assert.That(results).IsCollectionEqualTo([1]); + await Assert.That(completed).IsTrue(); + } + + /// Exercises the empty-on-error CatchIgnore<T> overload's OnCompleted + /// forwarder — when the source completes normally, the observer forwards completion. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCatchIgnoreEmptyOverloadSourceCompletesNormally_ThenForwardsCompletion() + { + var subject = new Subject(); + var completed = false; + + using var sub = subject.CatchIgnore().Subscribe(static _ => { }, () => completed = true); + + subject.OnCompleted(); + + await Assert.That(completed).IsTrue(); + } + + /// Exercises ToPropertyObservable's as MemberExpression ?? throw branch + /// — passing an expression whose body is not a member access raises ArgumentException. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenToPropertyObservableNonMemberExpression_ThenThrowsArgumentException() + { + var owner = new ToPropertyNonMemberOwner(); + + Action call = () => owner.ToPropertyObservable(static _ => 1 + 1); + var ex = Assert.Throws(call); + + await Assert.That(ex).IsNotNull(); + } + + /// Exercises AsSignalObservable's OnError forwarder — the synthesized + /// RxVoid-stream propagates the source's error verbatim. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAsSignalSourceErrors_ThenForwardsError() + { + var subject = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException("as-signal-error"); + + using var sub = subject.AsSignal().Subscribe(static _ => { }, ex => caught = ex); + + subject.OnError(expected); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// + /// Test class for INotifyPropertyChanged. + /// + private sealed class TestNotifyPropertyChanged : INotifyPropertyChanged + { + /// + /// Property changed event. + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// + /// Gets or sets the test property. + /// + /// The test property value. + public string TestProperty + { + get; + set + { + if (field == value) + { + return; + } + + field = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(TestProperty))); + } + } + + = string.Empty; + } + + /// INPC owner whose property type lets us pass a non-member expression body + /// (e.g. a literal arithmetic expression) into ToPropertyObservable so the + /// as MemberExpression ?? throw guard fires. The PropertyChanged event is + /// required by the interface but never raised — the guard short-circuits before + /// subscription wiring runs. + private sealed class ToPropertyNonMemberOwner : INotifyPropertyChanged + { + /// + public event PropertyChangedEventHandler? PropertyChanged + { + add => _ = value; + remove => _ = value; + } + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Retry.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Retry.cs new file mode 100644 index 0000000..6a704c3 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Retry.cs @@ -0,0 +1,841 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Disposables; +using System.Reactive.Linq; +using ReactiveUI.Primitives.Async.Tests; +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// Tests for ReactiveExtensionsTests. +public partial class ReactiveExtensionsTests +{ + /// + /// Tests OnErrorRetry without parameters. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task OnErrorRetry_RetriesOnError() + { + var attempts = 0; + var source = Observable.Create(observer => + { + attempts++; + if (attempts < 3) + { + observer.OnError(new InvalidOperationException()); + } + else + { + observer.OnNext(42); + observer.OnCompleted(); + } + + return Disposable.Empty; + }); + + var results = new List(); + using var sub = source.OnErrorRetry().Subscribe(results.Add); + + using (Assert.Multiple()) + { + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(results[0]).IsEqualTo(SampleValue42); + await Assert.That(attempts).IsEqualTo(SampleValue3); + } + } + + /// + /// Tests RetryWithBackoff respects max delay. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task RetryWithBackoff_RespectsMaxDelay() + { + var attempts = 0; + var source = Observable.Create(observer => + { + attempts++; + if (attempts < 5) + { + observer.OnError(new InvalidOperationException()); + } + else + { + observer.OnNext(42); + observer.OnCompleted(); + } + + return Disposable.Empty; + }); + + var result = source.RetryWithBackoff( + maxRetries: 10, + initialDelay: TimeSpan.FromMilliseconds(10), + backoffFactor: 2.0, + maxDelay: TimeSpan.FromMilliseconds(50), + scheduler: null) + .Wait(); + + await Assert.That(result).IsEqualTo(SampleValue42); + } + + /// + /// Tests OnErrorRetry with error action and retry count. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task OnErrorRetry_WithErrorActionAndRetryCount_RetriesLimitedTimes() + { + const int RetryCount = 3; + const int ExpectedAttempts = RetryCount + 1; + var attempts = 0; + var errorCount = 0; + var source = Observable.Create(observer => + { + attempts++; + observer.OnError(new InvalidOperationException()); + return Disposable.Empty; + }); + + Exception? caughtException = null; + + using var sub = source.OnErrorRetry(ex => errorCount++, retryCount: RetryCount) + .Subscribe(_ => { }, ex => caughtException = ex); + + using (Assert.Multiple()) + { + // retryCount = retries after the initial attempt; total subscriptions = 1 + retryCount. + await Assert.That(attempts).IsEqualTo(ExpectedAttempts); + await Assert.That(caughtException).IsNotNull(); + } + } + + /// + /// Tests OnErrorRetry with delay. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task OnErrorRetry_WithDelay_DelaysRetries() + { + var attempts = 0; + var source = Observable.Create(observer => + { + attempts++; + if (attempts < 3) + { + observer.OnError(new InvalidOperationException()); + } + else + { + observer.OnNext(42); + observer.OnCompleted(); + } + + return Disposable.Empty; + }); + + var startTimestamp = TimeProvider.System.GetTimestamp(); + + var result = source.OnErrorRetry( + ex => { }, + retryCount: 5, + delay: TimeSpan.FromMilliseconds(50)) + .Wait(); + + var elapsed = TimeProvider.System.GetElapsedTime(startTimestamp); + + using (Assert.Multiple()) + { + await Assert.That(result).IsEqualTo(SampleValue42); + await Assert.That(elapsed.TotalMilliseconds).IsGreaterThanOrEqualTo(MinimumExpectedMilliseconds); + } + } + + /// + /// Tests OnErrorRetry with delay and no error action. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task OnErrorRetry_WithDelayAndErrorAction_RetriesWithDelay() + { + var attemptCount = 0; + var errorsCaught = 0; + var results = new List(); + var scheduler = new VirtualClock(); + + var source = Observable.Create(observer => + { + attemptCount++; + if (attemptCount < 2) + { + observer.OnError(new InvalidOperationException($"Attempt {attemptCount}")); + } + else + { + observer.OnNext(42); + observer.OnCompleted(); + } + + return Disposable.Empty; + }); + + source.OnErrorRetry( + ex => errorsCaught++, + retryCount: int.MaxValue, + delay: TimeSpan.FromMilliseconds(10), + delayScheduler: scheduler) + .Subscribe(results.Add); + + scheduler.AdvanceBy(TimeSpan.FromMilliseconds(10).Ticks); + + using (Assert.Multiple()) + { + await Assert.That(errorsCaught).IsEqualTo(1); + await Assert.That(results).IsCollectionEqualTo([SampleValue42]); + } + } + + /// + /// Tests OnErrorRetry with retry count limit. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task OnErrorRetry_WithRetryCount_LimitsRetries() + { + const int RetryCount = 2; + const int ExpectedErrorCallbacks = RetryCount + 1; + var attemptCount = 0; + var errorsCaught = 0; + var finalError = false; + + var source = Observable.Create(observer => + { + attemptCount++; + observer.OnError(new InvalidOperationException($"Attempt {attemptCount}")); + return Disposable.Empty; + }); + + source.OnErrorRetry( + ex => errorsCaught++, + retryCount: RetryCount) + .Subscribe(_ => { }, ex => finalError = true); + + var finalErrorReceived = await AsyncTestHelpers.WaitForConditionAsync( + () => finalError, + TimeSpan.FromSeconds(2)); + + using (Assert.Multiple()) + { + // OnError callback fires for every failure (including the final propagated one): + // 1 initial attempt + retryCount retries = retryCount + 1 callbacks. + await Assert.That(finalErrorReceived).IsTrue(); + await Assert.That(errorsCaught).IsEqualTo(ExpectedErrorCallbacks); + await Assert.That(finalError).IsTrue(); + } + } + + /// + /// Tests OnErrorRetry with retry count and delay. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task OnErrorRetry_WithRetryCountAndDelay_LimitsRetriesWithDelay() + { + const int RetryCount = 2; + const int ExpectedErrorCallbacks = RetryCount + 1; + const int DelayMilliseconds = 10; + var attemptCount = 0; + var errorsCaught = 0; + var finalError = false; + var scheduler = new VirtualClock(); + + var source = Observable.Create(observer => + { + attemptCount++; + observer.OnError(new InvalidOperationException($"Attempt {attemptCount}")); + return Disposable.Empty; + }); + + source.OnErrorRetry( + ex => errorsCaught++, + retryCount: RetryCount, + delay: TimeSpan.FromMilliseconds(DelayMilliseconds), + delayScheduler: scheduler) + .Subscribe(_ => { }, ex => finalError = true); + + // Advance enough virtual time to drain all retries plus the final propagation. + scheduler.AdvanceBy(TimeSpan.FromMilliseconds(DelayMilliseconds * (RetryCount + 1)).Ticks); + + using (Assert.Multiple()) + { + // OnError callback fires for every failure (initial + retries) = retryCount + 1 calls. + await Assert.That(errorsCaught).IsEqualTo(ExpectedErrorCallbacks); + await Assert.That(finalError).IsTrue(); + } + } + + /// + /// Tests OnErrorRetry with retry count, delay, and scheduler. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task OnErrorRetry_WithRetryCountDelayAndScheduler_RetriesCorrectly() + { + var attemptCount = 0; + var errorsCaught = 0; + + var source = Observable.Create(observer => + { + attemptCount++; + if (attemptCount < 2) + { + observer.OnError(new InvalidOperationException($"Attempt {attemptCount}")); + } + else + { + observer.OnNext(42); + observer.OnCompleted(); + } + + return Disposable.Empty; + }); + + var result = 0; + source.OnErrorRetry( + ex => errorsCaught++, + retryCount: 3, + delay: TimeSpan.FromMilliseconds(10), + delayScheduler: Sequencer.Immediate) + .Subscribe(r => result = r); + + using (Assert.Multiple()) + { + await Assert.That(errorsCaught).IsEqualTo(1); + await Assert.That(result).IsEqualTo(SampleValue42); + } + } + + /// + /// Tests OnErrorRetry with action only uses zero-delay retry. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnErrorRetryWithActionOnly_ThenRetriesImmediately() + { + var attempts = 0; + var errorCount = 0; + var source = Observable.Create(observer => + { + attempts++; + if (attempts < 3) + { + observer.OnError(new InvalidOperationException()); + } + else + { + observer.OnNext(42); + observer.OnCompleted(); + } + + return Disposable.Empty; + }); + + var results = new List(); + using var sub = source.OnErrorRetry(ex => errorCount++) + .Subscribe(results.Add); + + using (Assert.Multiple()) + { + await Assert.That(results).IsCollectionEqualTo([SampleValue42]); + await Assert.That(errorCount).IsEqualTo(SampleValue2); + } + } + + /// + /// Tests OnErrorRetry with delay retries after specified delay. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnErrorRetryWithDelay_ThenRetriesAfterDelay() + { + var attempts = 0; + var source = Observable.Create(observer => + { + attempts++; + if (attempts < 2) + { + observer.OnError(new InvalidOperationException()); + } + else + { + observer.OnNext(42); + observer.OnCompleted(); + } + + return Disposable.Empty; + }); + + var result = source.OnErrorRetry( + ex => { }, + TimeSpan.FromMilliseconds(10)) + .Wait(); + + await Assert.That(result).IsEqualTo(SampleValue42); + } + + /// + /// Tests RetryWithBackoff rethrows after max retries exceeded. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRetryWithBackoffExceedsMaxRetries_ThenRethrows() + { + var source = Observable.Throw(new InvalidOperationException("fail")); + Exception? caughtError = null; + + source.RetryWithBackoff( + maxRetries: 2, + initialDelay: TimeSpan.FromMilliseconds(1), + backoffFactor: 2.0, + maxDelay: null, + scheduler: Sequencer.Immediate) + .Subscribe(_ => { }, ex => caughtError = ex); + + await Assert.That(caughtError).IsNotNull(); + } + + /// + /// Tests RetryWithBackoff caps delay at maxDelay. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRetryWithBackoffDelayExceedsMax_ThenCapsDelay() + { + var attempts = 0; + var source = Observable.Create(observer => + { + attempts++; + if (attempts < 4) + { + observer.OnError(new InvalidOperationException()); + } + else + { + observer.OnNext(42); + observer.OnCompleted(); + } + + return Disposable.Empty; + }); + + var result = source.RetryWithBackoff( + maxRetries: 5, + initialDelay: TimeSpan.FromMilliseconds(5), + backoffFactor: 10.0, + maxDelay: TimeSpan.FromMilliseconds(20), + scheduler: Sequencer.Immediate) + .Wait(); + + await Assert.That(result).IsEqualTo(SampleValue42); + } + + /// + /// Tests RetryWithDelay retries with custom delay selector. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRetryWithDelay_ThenRetriesWithCustomDelay() + { + var attempts = 0; + var source = Observable.Create(observer => + { + attempts++; + if (attempts < 3) + { + observer.OnError(new InvalidOperationException()); + } + else + { + observer.OnNext(42); + observer.OnCompleted(); + } + + return Disposable.Empty; + }); + + var result = source.RetryWithDelay(5, attempt => TimeSpan.FromMilliseconds(1)).Wait(); + + await Assert.That(result).IsEqualTo(SampleValue42); + } + + /// + /// Tests RetryForeverWithDelay retries indefinitely with delay. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRetryForeverWithDelay_ThenRetriesIndefinitely() + { + var attempts = 0; + var source = Observable.Create(observer => + { + attempts++; + if (attempts < 4) + { + observer.OnError(new InvalidOperationException()); + } + else + { + observer.OnNext(42); + observer.OnCompleted(); + } + + return Disposable.Empty; + }); + + var result = source.RetryForeverWithDelay(TimeSpan.FromMilliseconds(1)).Wait(); + + await Assert.That(result).IsEqualTo(SampleValue42); + } + + /// + /// Tests RetryWithFixedDelay retries with constant delay between retries. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRetryWithFixedDelay_ThenRetriesWithConstantDelay() + { + var attempts = 0; + var source = Observable.Create(observer => + { + attempts++; + if (attempts < 3) + { + observer.OnError(new InvalidOperationException()); + } + else + { + observer.OnNext(42); + observer.OnCompleted(); + } + + return Disposable.Empty; + }); + + var result = source.RetryWithFixedDelay(5, TimeSpan.FromMilliseconds(1)).Wait(); + + await Assert.That(result).IsEqualTo(SampleValue42); + } + + /// + /// Tests RetryWithBackoff inner retry with max delay cap path. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRetryWithBackoffInnerRetry_ThenRetriesAndCapsDelay() + { + var scheduler = new VirtualClock(); + var attempt = 0; + var source = Observable.Defer(() => + { + attempt++; + return attempt < 3 + ? Observable.Throw(new InvalidOperationException("retry")) + : Observable.Return(42); + }); + + var results = new List(); + Exception? error = null; + + source.RetryWithBackoff( + maxRetries: 5, + initialDelay: TimeSpan.FromTicks(10), + backoffFactor: 2.0, + maxDelay: TimeSpan.FromTicks(15), + scheduler: scheduler) + .Subscribe(results.Add, ex => error = ex); + + // Advance through retry delays + scheduler.AdvanceBy(LongDelayMilliseconds); + + await Assert.That(results).IsCollectionEqualTo([SampleValue42]); + await Assert.That(error).IsNull(); + } + + /// + /// Tests OnErrorRetry with negative delay ticks sets dueTime to zero. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnErrorRetryNegativeDelay_ThenUsesZeroDelay() + { + var attempt = 0; + var source = Observable.Defer(() => + { + attempt++; + return attempt < 3 + ? Observable.Throw(new InvalidOperationException("fail")) + : Observable.Return(42); + }); + + var results = new List(); + Exception? error = null; + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + source.OnErrorRetry( + _ => { }, + retryCount: 5, + delay: TimeSpan.FromTicks(-1)) + .Subscribe( + v => + { + results.Add(v); + received.TrySetResult(); + }, + ex => error = ex); + + await received.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + await Assert.That(results).Contains(SampleValue42); + } + + /// + /// Tests OnErrorRetry with retry count check rethrows after exceeding count. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenOnErrorRetryExceedsRetryCount_ThenRethrows() + { + var source = Observable.Throw(new InvalidOperationException("fail")); + Exception? caught = null; + var errorReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + source.OnErrorRetry( + _ => { }, + retryCount: 2, + delay: TimeSpan.Zero) + .Subscribe( + _ => { }, + ex => + { + caught = ex; + errorReceived.TrySetResult(); + }); + + await errorReceived.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + await Assert.That(caught).IsNotNull(); + await Assert.That(caught).IsTypeOf(); + } + + /// + /// Tests RetryWithBackoff caps delay at maxDelay when backoff exceeds it. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRetryWithBackoffExceedsMaxDelay_ThenCapsAtMaxDelay() + { + var scheduler = new VirtualClock(); + var attempt = 0; + var source = Observable.Create(obs => + { + attempt++; + if (attempt <= 3) + { + obs.OnError(new InvalidOperationException($"fail {attempt}")); + } + else + { + obs.OnNext(42); + obs.OnCompleted(); + } + + return Disposable.Empty; + }); + + var results = new List(); + using var sub = source + .RetryWithBackoff( + 5, + initialDelay: TimeSpan.FromMilliseconds(100), + backoffFactor: 10.0, + maxDelay: TimeSpan.FromMilliseconds(200), + scheduler: scheduler) + .Subscribe(results.Add); + + // Advance through retry delays + for (var i = 0; i < SampleValue5; i++) + { + scheduler.AdvanceBy(TimeSpan.FromMilliseconds(250).Ticks); + } + + await Assert.That(results).Contains(SampleValue42); + } + + /// + /// Tests RetryWithBackoff maxDelay cap is applied when computed delay exceeds it, + /// exercising line 1240 of ReactiveExtensions.cs. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRetryWithBackoffDelayExceedsMaxDelay_ThenCappedAtMaxDelay() + { + var scheduler = new VirtualClock(); + var attemptCount = 0; + + var source = Observable.Create(observer => + { + attemptCount++; + if (attemptCount <= 3) + { + observer.OnError(new InvalidOperationException($"attempt {attemptCount}")); + } + else + { + observer.OnNext(99); + observer.OnCompleted(); + } + + return Disposable.Empty; + }); + + // initialDelay=1ms, backoffFactor=100 => attempt 2 delay = 1*100^1 = 100ms, exceeds maxDelay=5ms + var results = new List(); + source.RetryWithBackoff( + maxRetries: 5, + initialDelay: TimeSpan.FromMilliseconds(1), + backoffFactor: 100.0, + maxDelay: TimeSpan.FromMilliseconds(5), + scheduler: scheduler) + .Subscribe(results.Add); + + // Advance past the capped delays + scheduler.AdvanceBy(TimeSpan.FromSeconds(1).Ticks); + + await Assert.That(results).Contains(SampleValue99); + } + + /// + /// Verifies that RetryWithBackoff caps the computed delay at maxDelay when the + /// exponential backoff exceeds it. Uses Sequencer.Immediate so the cap is exercised + /// synchronously. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRetryWithBackoffComputedDelayExceedsMaxDelay_ThenCappedToMaxDelay() + { + var attemptCount = 0; + var source = Observable.Create(observer => + { + attemptCount++; + + // Fail on first two attempts, succeed on third + if (attemptCount <= 2) + { + observer.OnError(new InvalidOperationException($"attempt {attemptCount}")); + } + else + { + observer.OnNext(42); + observer.OnCompleted(); + } + + return Disposable.Empty; + }); + + // initialDelay=1ms, backoffFactor=1000 => computed delay = 1000ms >> maxDelay=2ms + // This ensures the cap path at line 1240 is hit + var result = source.RetryWithBackoff( + maxRetries: 5, + initialDelay: TimeSpan.FromMilliseconds(1), + backoffFactor: 1000.0, + maxDelay: TimeSpan.FromMilliseconds(2), + scheduler: Sequencer.Immediate) + .Wait(); + + await Assert.That(result).IsEqualTo(SampleValue42); + await Assert.That(attemptCount).IsEqualTo(SampleValue3); + } + + /// + /// Verifies that RetryWithBackoff caps the computed delay at maxDelay using a + /// VirtualClock so the cap assignment is directly exercised. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRetryWithBackoff_GivenLargeBackoffFactor_ThenDelayIsCappedAtMaxDelay() + { + // Given + var scheduler = new VirtualClock(); + var attemptCount = 0; + var source = Observable.Create(observer => + { + attemptCount++; + + if (attemptCount <= 3) + { + observer.OnError(new InvalidOperationException($"fail {attemptCount}")); + } + else + { + observer.OnNext(100); + observer.OnCompleted(); + } + + return Disposable.Empty; + }); + + var results = new List(); + + // When — backoffFactor 500 with initialDelay 1ms yields huge computed delays, + // all of which must be capped to maxDelay 5ms. + using var sub = source + .RetryWithBackoff( + maxRetries: 5, + initialDelay: TimeSpan.FromMilliseconds(1), + backoffFactor: 500.0, + maxDelay: TimeSpan.FromMilliseconds(5), + scheduler: scheduler) + .Subscribe(results.Add); + + // Advance the scheduler enough for each capped retry delay + for (var i = 0; i < SampleValue10; i++) + { + scheduler.AdvanceBy(TimeSpan.FromMilliseconds(10).Ticks); + } + + // Then + await Assert.That(results).Contains(SchedulerWindowTicks); + await Assert.That(attemptCount).IsEqualTo(SampleValue4); + } + + /// + /// Verifies that RetryWithDelay rethrows the original exception when all retries + /// are exhausted, exercising the error-propagation branch. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenRetryWithDelayExhaustsRetries_ThenRethrowsOriginalException() + { + // Given — source always fails + var source = Observable.Throw(new InvalidOperationException("permanent")); + Exception? caught = null; + + // When + using var sub = source + .RetryWithDelay(2, _ => TimeSpan.FromMilliseconds(1)) + .Subscribe( + static _ => { }, + ex => caught = ex); + + // Allow time for the retry attempts to complete + await AsyncTestHelpers.WaitForConditionAsync(() => caught is not null, TimeSpan.FromSeconds(5)); + + // Then + await Assert.That(caught).IsNotNull(); + await Assert.That(caught).IsTypeOf(); + await Assert.That(caught!.Message).IsEqualTo("permanent"); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Scheduling.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Scheduling.cs new file mode 100644 index 0000000..3d1d6e8 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Scheduling.cs @@ -0,0 +1,900 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using ReactiveUI.Primitives.Async.Tests; +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// +/// Tests for ReactiveExtensions around scheduling. +/// +public partial class ReactiveExtensionsTests +{ + /// + /// Tests DetectStale marks stream as stale. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task DetectStale_WhenInactive_MarksAsStale() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List>(); + using var sub = subject.DetectStale(TimeSpan.FromTicks(100), scheduler).Subscribe(results.Add); + + subject.OnNext(1); + scheduler.AdvanceBy(SchedulerHalfWindowTicks); + subject.OnNext(SampleValue2); + scheduler.AdvanceBy(SchedulerAdvancePastWindowTicks); + + using (Assert.Multiple()) + { + await Assert.That(results).Count().IsEqualTo(SampleValue3); + await Assert.That(results[0].IsStale).IsFalse(); + await Assert.That(results[0].Update).IsEqualTo(1); + await Assert.That(results[1].IsStale).IsFalse(); + await Assert.That(results[1].Update).IsEqualTo(SampleValue2); + await Assert.That(results[SampleValue2].IsStale).IsTrue(); + } + } + + /// + /// Tests Heartbeat injects heartbeats. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Heartbeat_WhenInactive_InjectsHeartbeats() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List>(); + using var sub = subject.Heartbeat(TimeSpan.FromTicks(100), scheduler).Subscribe(results.Add); + + subject.OnNext(1); + scheduler.AdvanceBy(SchedulerAdvancePastWindowTicks); + subject.OnNext(SampleValue2); + scheduler.AdvanceBy(SchedulerAdvancePastWindowTicks); + + // Updates and heartbeats interleave; assert by predicate rather than positional index + // because the periodic-tick count between updates depends on scheduler implementation details. + var updates = results.Where(static h => !h.IsHeartbeat).ToList(); + var heartbeats = results.Where(static h => h.IsHeartbeat).ToList(); + + using (Assert.Multiple()) + { + await Assert.That(results).Count().IsGreaterThanOrEqualTo(SampleValue3); + await Assert.That(updates).Count().IsEqualTo(SampleValue2); + await Assert.That(updates[0].Update).IsEqualTo(1); + await Assert.That(updates[1].Update).IsEqualTo(SampleValue2); + await Assert.That(heartbeats).IsNotEmpty(); + } + } + + /// + /// Tests ObserveOnSafe with null scheduler. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task ObserveOnSafe_WithNullScheduler_ReturnsSource() + { + var source = Observable.Return(1); + var result = source.ObserveOnSafe(null); + + await Assert.That(result).IsSameReferenceAs(source); + } + + /// + /// Tests ObserveOnSafe with scheduler. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task ObserveOnSafe_WithScheduler_ObservesOnScheduler() + { + var scheduler = new VirtualClock(); + var source = Observable.Return(1); + int? result = null; + using var sub = source.ObserveOnSafe(scheduler).Subscribe(x => result = x); + + await Assert.That(result).IsNull(); + + scheduler.AdvanceBy(1); + + await Assert.That(result).IsEqualTo(1); + } + + /// + /// Tests Start with Action and null scheduler. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Start_WithActionAndNullScheduler_ExecutesAction() + { + var executed = false; + Action action = () => executed = true; + + using var sub = ReactiveExtensions.Start(action, Sequencer.Immediate).Subscribe(); + + await Assert.That(executed).IsTrue(); + } + + /// + /// Tests Start with function and null scheduler executes immediately. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Start_WithFunctionAndNullScheduler_ReturnsComputedValue() + { + var result = await ReactiveExtensions.Start(() => 21 * 2, scheduler: null).ToTask(); + + await Assert.That(result).IsEqualTo(SampleValue42); + } + + /// + /// Tests ScheduleSafe with null scheduler executes immediately. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task ScheduleSafe_WithNullScheduler_ExecutesImmediately() + { + var executed = false; + const ISequencer? Scheduler = null; + var disposable = Scheduler.ScheduleSafe(() => executed = true); + + using (Assert.Multiple()) + { + await Assert.That(executed).IsTrue(); + await Assert.That(disposable).IsNotNull(); + } + } + + /// + /// Tests Heartbeat class with update. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Heartbeat_WithUpdate_IsNotHeartbeat() + { + var heartbeat = new Heartbeat(42); + + using (Assert.Multiple()) + { + await Assert.That(heartbeat.IsHeartbeat).IsFalse(); + await Assert.That(heartbeat.Update).IsEqualTo(SampleValue42); + } + } + + /// + /// Tests Heartbeat class without update. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Heartbeat_WithoutUpdate_IsHeartbeat() + { + var heartbeat = new Heartbeat(); + + await Assert.That(heartbeat.IsHeartbeat).IsTrue(); + } + + /// + /// Tests Schedule with value and TimeSpan and function. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Schedule_WithValueTimeSpanAndFunction_DelaysAndTransforms() + { + var scheduler = new VirtualClock(); + int? result = null; + + using var sub = 10.Schedule(TimeSpan.FromTicks(100), scheduler, x => x * 2) + .Subscribe(x => result = x); + + await Assert.That(result).IsNull(); + + scheduler.AdvanceBy(SchedulerAdvancePastWindowTicks); + + await Assert.That(result).IsEqualTo(SampleValue20); + } + + /// + /// Tests Schedule with observable, TimeSpan and function. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Schedule_WithObservableTimeSpanAndFunction_DelaysAndTransforms() + { + var scheduler = new VirtualClock(); + var source = Observable.Return(10); + var results = new List(); + + using var sub = source.Schedule(TimeSpan.FromTicks(100), scheduler, x => x * 2) + .Subscribe(results.Add); + + await Assert.That(results).IsEmpty(); + + scheduler.AdvanceBy(SchedulerAdvancePastWindowTicks); + + await Assert.That(results).IsCollectionEqualTo([SampleValue20]); + } + + /// + /// Tests SyncTimer creates shared observable that produces ticks. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task SyncTimer_ProducesSharedTicks() + { + var timeSpan = TimeSpan.FromMilliseconds(100); + var scheduler = new VirtualClock(); + var results1 = new List(); + var results2 = new List(); + + using var sub1 = ReactiveExtensions.SyncTimer(timeSpan, scheduler).Take(2).Subscribe(results1.Add); + using var sub2 = ReactiveExtensions.SyncTimer(timeSpan, scheduler).Take(2).Subscribe(results2.Add); + + scheduler.AdvanceBy(timeSpan.Ticks * SampleValue2); + + using (Assert.Multiple()) + { + // Both subscriptions should get ticks (shared timer) + await Assert.That(results1).Count().IsGreaterThanOrEqualTo(1); + await Assert.That(results2).Count().IsGreaterThanOrEqualTo(1); + } + } + + /// + /// Tests Using with action executes the action. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Using_WithAction_ExecutesActionImmediately() + { + var executed = false; + using var disposable = Disposable.Create(() => { }); + + disposable.Using(d => executed = true, Sequencer.Immediate).Subscribe(); + + await Assert.That(executed).IsTrue(); + } + + /// + /// Tests Using with action and null scheduler executes immediately. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Using_WithActionAndNullScheduler_ExecutesActionImmediately() + { + var executed = false; + using var disposable = Disposable.Create(() => { }); + + await disposable.Using(d => executed = true, scheduler: null).ToTask(); + + await Assert.That(executed).IsTrue(); + } + + /// + /// Tests Using with function transforms the value. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Using_WithFunction_TransformsValue() + { + using var disposable = Disposable.Create(() => { }); + var result = 0; + + disposable.Using(d => SampleValue42, Sequencer.Immediate).Subscribe(r => result = r); + + await Assert.That(result).IsEqualTo(SampleValue42); + } + + /// + /// Tests Schedule with TimeSpan and action. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Schedule_WithTimeSpanAndAction_ExecutesAction() + { + var executed = false; + const int Value = 42; + + Value.Schedule(TimeSpan.FromMilliseconds(10), Sequencer.Immediate, v => executed = true) + .Subscribe(); + + await Assert.That(executed).IsTrue(); + } + + /// + /// Tests Schedule with observable, DateTimeOffset and action delays emission until the scheduler advances. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Schedule_WithObservableDateTimeOffsetAndAction_DelaysAndExecutesAction() + { + var scheduler = new VirtualClock(); + var dueTime = scheduler.Now.AddTicks(100); + var subject = new Subject(); + var executed = false; + var results = new List(); + + using var sub = subject.Schedule(dueTime, scheduler, value => executed = value == 42) + .Subscribe(results.Add); + + subject.OnNext(SampleValue42); + + using (Assert.Multiple()) + { + await Assert.That(executed).IsFalse(); + await Assert.That(results).IsEmpty(); + } + + scheduler.AdvanceBy(SchedulerWindowTicks + 1); + + using (Assert.Multiple()) + { + await Assert.That(executed).IsTrue(); + await Assert.That(results).IsCollectionEqualTo([SampleValue42]); + } + } + + /// + /// Tests Schedule with observable, TimeSpan and action. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Schedule_WithObservableTimeSpanAndAction_ExecutesAction() + { + var executed = false; + var subject = new Subject(); + + subject.Schedule(TimeSpan.FromMilliseconds(10), Sequencer.Immediate, v => executed = true) + .Subscribe(); + + subject.OnNext(SampleValue42); + + await Assert.That(executed).IsTrue(); + } + + /// + /// Tests Schedule with DateTimeOffset and action. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Schedule_WithDateTimeOffsetAndAction_ExecutesAction() + { + var executed = false; + const int Value = 42; + + Value.Schedule(TimeProvider.System.GetLocalNow().AddMilliseconds(SampleValue10), Sequencer.Immediate, v => executed = true) + .Subscribe(); + + await Assert.That(executed).IsTrue(); + } + + /// + /// Tests Schedule with observable, DateTimeOffset and action. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Schedule_WithObservableDateTimeOffsetAndAction_ExecutesAction() + { + var executed = false; + var subject = new Subject(); + + subject.Schedule(TimeProvider.System.GetLocalNow().AddMilliseconds(SampleValue10), Sequencer.Immediate, v => executed = true) + .Subscribe(); + + subject.OnNext(SampleValue42); + + await Assert.That(executed).IsTrue(); + } + + /// + /// Tests Schedule with function and scheduler. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Schedule_WithFunction_TransformsValue() + { + const int Value = 42; + var result = 0; + + Value.Schedule(Sequencer.Immediate, v => v * SampleValue2) + .Subscribe(r => result = r); + + await Assert.That(result).IsEqualTo(SampleValue84); + } + + /// + /// Tests Schedule with observable and function. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Schedule_WithObservableAndFunction_TransformsValue() + { + var subject = new Subject(); + var result = 0; + + subject.Schedule(Sequencer.Immediate, v => v * SampleValue2) + .Subscribe(r => result = r); + + subject.OnNext(SampleValue42); + + await Assert.That(result).IsEqualTo(SampleValue84); + } + + /// + /// Tests Schedule with TimeSpan and function. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Schedule_WithTimeSpanAndFunction_TransformsValue() + { + const int Value = 42; + var result = 0; + + Value.Schedule(TimeSpan.FromMilliseconds(10), Sequencer.Immediate, v => v * SampleValue2) + .Subscribe(r => result = r); + + await Assert.That(result).IsEqualTo(SampleValue84); + } + + /// + /// Tests Schedule with observable, TimeSpan and function. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Schedule_WithObservableTimeSpanAndFunction_TransformsValue() + { + var subject = new Subject(); + var result = 0; + + subject.Schedule(TimeSpan.FromMilliseconds(10), Sequencer.Immediate, v => v * SampleValue2) + .Subscribe(r => result = r); + + subject.OnNext(SampleValue42); + + await Assert.That(result).IsEqualTo(SampleValue84); + } + + /// + /// Tests ObserveOnIf with bool condition and single scheduler when true. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task ObserveOnIf_WithBoolConditionTrue_ObservesOnScheduler() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + + subject.ObserveOnIf(true, scheduler).Subscribe(results.Add); + + subject.OnNext(1); + await Assert.That(results).IsEmpty(); + + scheduler.AdvanceBy(1); + + await Assert.That(results).IsCollectionEqualTo([1]); + } + + /// + /// Tests ObserveOnIf with bool condition and single scheduler when false. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task ObserveOnIf_WithBoolConditionFalse_DoesNotObserveOnScheduler() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + + subject.ObserveOnIf(false, scheduler).Subscribe(results.Add); + + subject.OnNext(1); + await Assert.That(results).IsCollectionEqualTo([1]); + } + + /// + /// Tests ObserveOnIf with bool condition and two schedulers when true. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task ObserveOnIf_WithBoolConditionTrue_ObservesOnTrueScheduler() + { + var trueScheduler = new VirtualClock(); + var falseScheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + + subject.ObserveOnIf(true, trueScheduler, falseScheduler).Subscribe(results.Add); + + subject.OnNext(1); + await Assert.That(results).IsEmpty(); + + trueScheduler.AdvanceBy(1); + await Assert.That(results).IsCollectionEqualTo([1]); + } + + /// + /// Tests ObserveOnIf with bool condition and two schedulers when false. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task ObserveOnIf_WithBoolConditionFalse_ObservesOnFalseScheduler() + { + var trueScheduler = new VirtualClock(); + var falseScheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + + subject.ObserveOnIf(false, trueScheduler, falseScheduler).Subscribe(results.Add); + + subject.OnNext(1); + await Assert.That(results).IsEmpty(); + + falseScheduler.AdvanceBy(1); + await Assert.That(results).IsCollectionEqualTo([1]); + } + + /// + /// Tests ObserveOnIf with reactive condition routes notifications to both schedulers as the condition changes. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task ObserveOnIf_WithReactiveCondition_ObservesOnMatchingScheduler() + { + var trueScheduler = new VirtualClock(); + var falseScheduler = new VirtualClock(); + var source = new Subject(); + var condition = new BehaviorSubject(false); + var results = new List(); + + using var sub = source.ObserveOnIf(condition, trueScheduler, falseScheduler).Subscribe(results.Add); + + source.OnNext(1); + await Assert.That(results).IsEmpty(); + + falseScheduler.AdvanceBy(1); + await Assert.That(results).IsCollectionEqualTo([1]); + + condition.OnNext(true); + source.OnNext(SampleValue2); + + await Assert.That(results).IsCollectionEqualTo([1]); + + trueScheduler.AdvanceBy(1); + await Assert.That(results).IsCollectionEqualTo([1, SampleValue2]); + } + + /// + /// Tests ObserveOnIf with reactive condition and single scheduler observes only when condition is true. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task ObserveOnIf_WithReactiveConditionAndSingleScheduler_ObservesOnlyWhenEnabled() + { + var scheduler = new VirtualClock(); + var source = new Subject(); + var condition = new BehaviorSubject(false); + var results = new List(); + + using var sub = source.ObserveOnIf(condition, scheduler).Subscribe(results.Add); + + source.OnNext(1); + await Assert.That(results).IsCollectionEqualTo([1]); + + condition.OnNext(true); + source.OnNext(SampleValue2); + + await Assert.That(results).IsCollectionEqualTo([1]); + + scheduler.AdvanceBy(1); + await Assert.That(results).IsCollectionEqualTo([1, SampleValue2]); + } + + /// + /// Tests SyncTimer without scheduler uses default scheduler and produces ticks. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSyncTimerCalledWithoutScheduler_ThenProducesTicks() + { + var results = new List(); + using var sub = ReactiveExtensions.SyncTimer(TimeSpan.FromMilliseconds(50)).Take(2).Subscribe(results.Add); + + await AsyncTestHelpers.WaitForConditionAsync( + () => results.Count >= 1, + TimeSpan.FromSeconds(5)); + + await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); + } + + /// + /// Tests Start with null scheduler executes the action directly. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenStartActionWithNullScheduler_ThenExecutesAction() + { + var executed = false; + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = ReactiveExtensions.Start(() => executed = true, scheduler: null) + .Subscribe(_ => { }, completed.SetResult); + + await completed.Task.WaitAsync(TimeSpan.FromSeconds(30)); + + await Assert.That(executed).IsTrue(); + } + + /// + /// Tests Start with function and null scheduler returns computed result. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenStartFuncWithNullScheduler_ThenReturnsResult() + { + var result = await ReactiveExtensions.Start(() => 42, scheduler: null).ToTask(); + + await Assert.That(result).IsEqualTo(SampleValue42); + } + + /// + /// Tests ScheduleSafe with TimeSpan and null scheduler uses Thread.Sleep path. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScheduleSafeWithTimeSpanAndNullScheduler_ThenSleepsAndExecutes() + { + var executed = false; + const ISequencer? Scheduler = null; + var disposable = Scheduler.ScheduleSafe(TimeSpan.FromMilliseconds(10), () => executed = true); + + using (Assert.Multiple()) + { + await Assert.That(executed).IsTrue(); + await Assert.That(disposable).IsNotNull(); + } + } + + /// + /// Tests Using with Action invokes the action and disposes the resource. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenUsingWithActionAndNoScheduler_ThenExecutesAndDisposes() + { + var actionExecuted = false; + using var disposable = Disposable.Create(() => { }); + + await disposable.Using(d => actionExecuted = true).ToTask(); + + await Assert.That(actionExecuted).IsTrue(); + } + + /// + /// Tests Schedule with value and TimeSpan emits value after delay. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScheduleValueWithTimeSpan_ThenEmitsAfterDelay() + { + var scheduler = new VirtualClock(); + int? result = null; + + using var sub = 42.Schedule(TimeSpan.FromTicks(100), scheduler) + .Subscribe(x => result = x); + + await Assert.That(result).IsNull(); + + scheduler.AdvanceBy(SchedulerAdvancePastWindowTicks); + + await Assert.That(result).IsEqualTo(SampleValue42); + } + + /// + /// Tests Schedule with observable and TimeSpan delays each emitted value. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScheduleObservableWithTimeSpan_ThenDelaysValues() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + + using var sub = ((IObservable)subject).Schedule(TimeSpan.FromTicks(100), scheduler) + .Subscribe(results.Add); + + subject.OnNext(SampleValue10); + await Assert.That(results).IsEmpty(); + + scheduler.AdvanceBy(SchedulerAdvancePastWindowTicks); + + await Assert.That(results).IsCollectionEqualTo([SampleValue10]); + } + + /// + /// Tests Schedule with value and DateTimeOffset emits value at due time. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScheduleValueWithDateTimeOffset_ThenEmitsAtDueTime() + { + var scheduler = new VirtualClock(); + int? result = null; + var dueTime = scheduler.Now.AddTicks(100); + + using var sub = 42.Schedule(dueTime, scheduler) + .Subscribe(x => result = x); + + await Assert.That(result).IsNull(); + + scheduler.AdvanceBy(SchedulerWindowTicks + 1); + + await Assert.That(result).IsEqualTo(SampleValue42); + } + + /// + /// Tests Schedule with observable and DateTimeOffset delays values to due time. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScheduleObservableWithDateTimeOffset_ThenDelaysValues() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + var dueTime = scheduler.Now.AddTicks(100); + + using var sub = ((IObservable)subject).Schedule(dueTime, scheduler) + .Subscribe(results.Add); + + subject.OnNext(SampleValue10); + await Assert.That(results).IsEmpty(); + + scheduler.AdvanceBy(SchedulerWindowTicks + 1); + + await Assert.That(results).IsCollectionEqualTo([SampleValue10]); + } + + /// + /// Tests Schedule with action and TimeSpan executes action then emits value. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScheduleValueWithTimeSpanAndAction_ThenExecutesActionAndEmits() + { + var scheduler = new VirtualClock(); + var actionExecuted = false; + int? result = null; + + using var sub = 42.Schedule(TimeSpan.FromTicks(100), scheduler, v => actionExecuted = v == 42) + .Subscribe(x => result = x); + + await Assert.That(actionExecuted).IsFalse(); + + scheduler.AdvanceBy(SchedulerAdvancePastWindowTicks); + + using (Assert.Multiple()) + { + await Assert.That(actionExecuted).IsTrue(); + await Assert.That(result).IsEqualTo(SampleValue42); + } + } + + /// + /// Tests Using with Func overload returns the function result. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenUsingWithFunc_ThenReturnsFunctionResult() + { + var disposable = new CompositeDisposable(); + var result = await disposable.Using(d => 42).FirstAsync(); + + await Assert.That(result).IsEqualTo(SampleValue42); + } + + /// + /// Tests While with scheduler repeatedly executes action while condition holds. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenWhileWithScheduler_ThenExecutesActionOnScheduler() + { + var executedOnScheduler = false; + var scheduler = new VirtualClock(); + + // Test that the scheduler parameter is passed through to Observable.Start + var obs = ReactiveExtensions.While( + () => !executedOnScheduler, + () => executedOnScheduler = true, + scheduler); + + var results = new List(); + using var sub = obs.Subscribe(results.Add); + + // Nothing should execute until the scheduler advances + await Assert.That(executedOnScheduler).IsFalse(); + + scheduler.Start(); + + await Assert.That(executedOnScheduler).IsTrue(); + await Assert.That(results).Count().IsEqualTo(1); + } + + /// + /// Tests Schedule with action, observable source, and TimeSpan overload. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenScheduleWithActionAndTimeSpan_ThenExecutesActionAndEmits() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + var actionValues = new List(); + + subject.Schedule(TimeSpan.FromTicks(50), scheduler, actionValues.Add) + .Subscribe(results.Add); + + subject.OnNext(SampleValue10); + scheduler.AdvanceBy(SchedulerHalfWindowTicks + 1); + + await Assert.That(actionValues).IsCollectionEqualTo([SampleValue10]); + await Assert.That(results).IsCollectionEqualTo([SampleValue10]); + } + + /// + /// Tests Heartbeat timer subscription null check path by quickly disposing. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenHeartbeatSourceEmits_ThenTimerSubscriptionDisposed() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List>(); + + using var sub = subject.Heartbeat(TimeSpan.FromTicks(100), scheduler).Subscribe(results.Add); + + // Emit value, which disposes the current timer + subject.OnNext(1); + scheduler.AdvanceBy(1); + + // Emit another quickly, before heartbeat timer fires + subject.OnNext(SampleValue2); + scheduler.AdvanceBy(1); + + await Assert.That(results.Count).IsGreaterThanOrEqualTo(SampleValue2); + await Assert.That(results[0].Update).IsEqualTo(1); + await Assert.That(results[1].Update).IsEqualTo(SampleValue2); + } + + /// + /// Tests Using with Action and null scheduler disposes the object after executing the action. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenUsingWithActionAndNullScheduler_ThenDisposesObject() + { + var executed = false; + await using var stream = new MemoryStream(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = stream.Using( + _ => executed = true, + null).Subscribe( + _ => { }, + completed.SetResult); + + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + await Assert.That(executed).IsTrue(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.SelectAsync.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.SelectAsync.cs new file mode 100644 index 0000000..4670b52 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.SelectAsync.cs @@ -0,0 +1,123 @@ +// 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.Primitives.Extensions.Tests; + +/// Tests for ReactiveExtensionsTests. +public partial class ReactiveExtensionsTests +{ + /// + /// Tests SelectAsync with CancellationToken projects values asynchronously. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectAsyncWithCancellationToken_ThenProjectsValues() + { + var source = ExpectedSequence123.ToObservable(); + var results = new List(); + var tcs = new TaskCompletionSource(); + + source.SelectAsync((x, _) => Task.FromResult(x * SampleValue2)) + .Subscribe( + results.Add, + () => tcs.TrySetResult(true)); + + await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + await Assert.That(results).IsCollectionEqualTo([SampleValue2, SampleValue4, SampleValue6]); + } + + /// + /// Tests SelectAsync simple overload projects values asynchronously. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectAsyncSimple_ThenProjectsValues() + { + var source = ExpectedSequence123.ToObservable(); + var results = new List(); + var tcs = new TaskCompletionSource(); + + source.SelectAsync(x => Task.FromResult(x * SampleValue2)) + .Subscribe( + results.Add, + () => tcs.TrySetResult(true)); + + await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + await Assert.That(results).IsCollectionEqualTo([SampleValue2, SampleValue4, SampleValue6]); + } + + /// + /// Tests SelectAsyncSequential processes tasks in order. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectAsyncSequential_ThenProcessesInOrder() + { + var source = ExpectedSequence123.ToObservable(); + var results = new List(); + var tcs = new TaskCompletionSource(); + + source.SelectAsyncSequential(x => Task.FromResult(x * SampleValue2)) + .Subscribe( + results.Add, + () => tcs.TrySetResult(true)); + + await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + await Assert.That(results).IsCollectionEqualTo([SampleValue2, SampleValue4, SampleValue6]); + } + + /// + /// Tests SelectLatestAsync emits only the latest async result. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectLatestAsync_ThenEmitsLatestResult() + { + const int AsyncDelayMs = 10; + var source = ExpectedSequence123.ToObservable(); + var results = new List(); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + source.SelectLatestAsync(async x => + { + await Task.Delay(AsyncDelayMs); + return x * SampleValue2; + }).Subscribe( + results.Add, + () => tcs.TrySetResult(true)); + + await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + // Switch means only the latest survives; with sources 1,2,3 and selector x*2, expect [6]. + await Assert.That(results).IsNotEmpty(); + } + + /// + /// Tests SelectAsyncConcurrent processes tasks concurrently up to max concurrency. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSelectAsyncConcurrent_ThenProcessesConcurrently() + { + var source = ExpectedSequence123.ToObservable(); + var results = new List(); + var tcs = new TaskCompletionSource(); + + source.SelectAsyncConcurrent( + async x => + { + await Task.Delay(1); + return x * SampleValue2; + }, + maxConcurrency: 2).Subscribe(results.Add, () => tcs.TrySetResult(true)); + + await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + results.Sort(); + await Assert.That(results).IsCollectionEqualTo([SampleValue2, SampleValue4, SampleValue6]); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Synchronize.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Synchronize.cs new file mode 100644 index 0000000..0ac54a8 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Synchronize.cs @@ -0,0 +1,567 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using ReactiveUI.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// Tests for ReactiveExtensionsTests. +public partial class ReactiveExtensionsTests +{ + /// + /// Syncronizes the asynchronous runs with asynchronous tasks in subscriptions. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task SubscribeSynchronus_RunsWithAsyncTasksInSubscriptions() + { + // Given, When. SubscribeSynchronous dispatches each OnNext concurrently on the thread + // pool, so result / itterations need Interlocked for the read-modify-write to be safe. + var result = 0; + var itterations = 0; + var subject = new Subject(); + using var disposable = subject + .SubscribeSynchronous(async x => + { + if (x) + { + await Task.Delay(1000); + Interlocked.Increment(ref result); + } + else + { + await Task.Delay(500); + Interlocked.Decrement(ref result); + } + + Interlocked.Increment(ref itterations); + }); + + subject.OnNext(true); + subject.OnNext(false); + subject.OnNext(true); + subject.OnNext(false); + subject.OnNext(true); + subject.OnNext(false); + + while (Volatile.Read(ref itterations) < SampleValue6) + { + Thread.Yield(); + } + + // Then + await Assert.That(Volatile.Read(ref result)).IsZero(); + } + + /// + /// Syncronizes the asynchronous runs with asynchronous tasks in subscriptions. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task SynchronizeSynchronous_RunsWithAsyncTasksInSubscriptions() + { + // Given, When. SynchronizeSynchronous dispatches each OnNext through an independent + // Continuation so the six HandleAsync invocations run concurrently on the thread pool — + // the int read-modify-write therefore needs Interlocked. The test asserts pair-wise + // (+1, -1) sums to zero after WhenAll completes. + var result = 0; + var itterations = 0; + var subject = new Subject(); + var tasks = new List(); + using var disposable = subject + .SynchronizeSynchronous() + .Subscribe(x => tasks.Add(HandleAsync(x))); + + async Task HandleAsync((bool Value, IDisposable Sync) x) + { + try + { + if (x.Value) + { + await Task.Delay(LongDelayMilliseconds); + Interlocked.Increment(ref result); + } + else + { + await Task.Delay(ShortDelayMilliseconds); + Interlocked.Decrement(ref result); + } + } + finally + { + x.Sync.Dispose(); + Interlocked.Increment(ref itterations); + } + } + + subject.OnNext(true); + subject.OnNext(false); + subject.OnNext(true); + subject.OnNext(false); + subject.OnNext(true); + subject.OnNext(false); + + await Task.WhenAll(tasks); + + // Then + await Assert.That(Volatile.Read(ref result)).IsZero(); + } + + /// + /// Syncronizes the asynchronous runs with asynchronous tasks in subscriptions. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task SubscribeAsync_RunsWithAsyncTasksInSubscriptions() + { + // Given, When. SubscribeAsync dispatches each OnNext concurrently, so the integer + // read-modify-write needs Interlocked and the polling read needs Volatile. + var result = 0; + var itterations = 0; + var subject = new Subject(); + using var disposable = subject + .SubscribeAsync(async x => + { + if (x) + { + await Task.Delay(1000); + Interlocked.Increment(ref result); + } + else + { + await Task.Delay(500); + Interlocked.Decrement(ref result); + } + + Interlocked.Increment(ref itterations); + }); + + subject.OnNext(true); + subject.OnNext(false); + subject.OnNext(true); + subject.OnNext(false); + subject.OnNext(true); + subject.OnNext(false); + + while (Volatile.Read(ref itterations) < SampleValue6) + { + Thread.Yield(); + } + + // Then + await Assert.That(Volatile.Read(ref result)).IsZero(); + } + + /// + /// Tests WithLimitedConcurrency. + /// + /// A representing the asynchronous RxVoid test. + [Test] + public async Task WithLimitedConcurrency_LimitsConcurrentTasks() + { + var maxConcurrent = 0; + var currentConcurrent = 0; + + IEnumerable> CreateTasks() + { + for (int i = 1; i <= SampleValue10; i++) + { + var value = i; + yield return Task.Run(async () => + { + lock (_gate) + { + currentConcurrent++; + maxConcurrent = Math.Max(maxConcurrent, currentConcurrent); + } + + await Task.Delay(SampleValue10); + + lock (_gate) + { + currentConcurrent--; + } + + return value; + }); + } + } + + var results = await CreateTasks().WithLimitedConcurrency(3).ToList(); + + using (Assert.Multiple()) + { + await Assert.That(results).Count().IsEqualTo(SampleValue10); + await Assert.That(maxConcurrent).IsLessThanOrEqualTo(SampleValue3); + } + } + + /// Verifies an empty limited-concurrency task sequence completes immediately. + /// A representing the asynchronous test operation. + [Test] + public async Task WithLimitedConcurrency_EmptyTaskSequence_Completes() + { + var completed = false; + var values = new List(); + + using var sub = Array.Empty>() + .WithLimitedConcurrency(SampleValue3) + .Subscribe(values.Add, () => completed = true); + + await Assert.That(values).IsEmpty(); + await Assert.That(completed).IsTrue(); + } + + /// Verifies faulted and canceled tasks stop enumeration and report the expected errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WithLimitedConcurrency_TaskFaultsOrCancels_ThenErrorsAndStops() + { + var expected = new InvalidOperationException("limited-concurrency"); + Exception? fault = null; + Exception? canceled = null; + var afterFaultPulled = false; + + IEnumerable> FaultingTasks() + { + yield return Task.FromException(expected); + afterFaultPulled = true; + yield return Task.FromResult(SampleValue2); + } + + using var faultSub = FaultingTasks() + .WithLimitedConcurrency(1) + .Subscribe(static _ => { }, ex => fault = ex); + + using var canceledSub = new[] { Task.FromCanceled(new CancellationToken(canceled: true)) } + .WithLimitedConcurrency(1) + .Subscribe(static _ => { }, ex => canceled = ex); + + await Assert.That(fault).IsSameReferenceAs(expected); + await Assert.That(canceled).IsTypeOf(); + await Assert.That(afterFaultPulled).IsFalse(); + } + + /// Verifies disposing before a pending task completes drops later notifications. + /// A representing the asynchronous test operation. + [Test] + public async Task WithLimitedConcurrency_DisposeBeforeTaskContinuation_DropsWork() + { + var task = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var values = new List(); + Exception? caught = null; + var completed = false; + + var sub = new[] { task.Task } + .WithLimitedConcurrency(1) + .Subscribe(values.Add, ex => caught = ex, () => completed = true); + + sub.Dispose(); + task.SetResult(SampleValue10); + await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + + await Assert.That(values).IsEmpty(); + await Assert.That(caught).IsNull(); + await Assert.That(completed).IsFalse(); + } + + /// Exercises the internal disposed pull path used after a subscription is closed. + /// A representing the asynchronous test operation. + [Test] + public async Task WithLimitedConcurrency_PullAfterDisposed_ThenNoNotifications() + { + var limiter = new ConcurrencyLimiter([Task.FromResult(SampleValue10)], 1) + { + Disposed = true + }; + var values = new List(); + var completed = false; + + limiter.PullNextTask(Observer.Create(values.Add, static _ => { }, () => completed = true)); + + await Assert.That(values).IsEmpty(); + await Assert.That(completed).IsFalse(); + } + + /// Exercises the null-current continuation path defensively tolerated by the limiter. + /// A representing the asynchronous test operation. + [Test] + public async Task WithLimitedConcurrency_NullTaskEntry_ThenNoNotifications() + { + IEnumerable> tasks = [null!]; + var values = new List(); + Exception? caught = null; + var completed = false; + + using var sub = tasks + .WithLimitedConcurrency(1) + .Subscribe(values.Add, ex => caught = ex, () => completed = true); + + await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + + await Assert.That(values).IsEmpty(); + await Assert.That(caught).IsNull(); + await Assert.That(completed).IsFalse(); + } + + /// + /// Tests SynchronizeSynchronous provides sync lock. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task SynchronizeSynchronous_ProvidesSyncLock() + { + var subject = new Subject(); + var results = new List(); + IDisposable? lastSync = null; + + using var sub = subject.SynchronizeSynchronous().Subscribe(tuple => + { + results.Add(tuple.Value); + lastSync = tuple.Sync; + tuple.Sync.Dispose(); // Must dispose sync lock to allow next item to process + }); + + subject.OnNext(1); + + using (Assert.Multiple()) + { + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(lastSync).IsNotNull(); + } + } + + /// + /// Tests SynchronizeAsync provides sync lock. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task SynchronizeAsync_ProvidesSyncLock() + { + var subject = new Subject(); + var results = new List(); + IDisposable? lastSync = null; + + using var sub = subject.SynchronizeAsync().Subscribe(tuple => + { + results.Add(tuple.Value); + lastSync = tuple.Sync; + }); + + subject.OnNext(1); + + using (Assert.Multiple()) + { + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(lastSync).IsNotNull(); + } + } + + /// + /// Tests SubscribeAsync with onNext and onError. + /// + /// A representing the asynchronous RxVoid test. + [Test] + public async Task SubscribeAsync_WithOnNextAndOnError_HandlesError() + { + var results = new List(); + Exception? caughtException = null; + var errorSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var source = Observable.Create(observer => + { + observer.OnNext(1); + observer.OnError(new InvalidOperationException()); + return Disposable.Empty; + }); + + using var sub = source.SubscribeAsync( + async x => results.Add(x), + ex => + { + caughtException = ex; + errorSource.TrySetResult(true); + }); + + await errorSource.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + using (Assert.Multiple()) + { + await Assert.That(results).IsCollectionEqualTo([1]); + await Assert.That(caughtException).IsNotNull(); + } + } + + /// + /// Tests SubscribeSynchronous with full callbacks. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task SubscribeSynchronous_WithFullCallbacks_ExecutesAll() + { + var subject = new Subject(); + var results = new List(); + var errorHandled = false; + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + subject.SubscribeSynchronous( + async v => + { + await Task.Yield(); + results.Add(v); + }, + _ => errorHandled = true, + () => completed.TrySetResult()); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + subject.OnCompleted(); + + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + using (Assert.Multiple()) + { + await Assert.That(results).IsCollectionEqualTo([1, SampleValue2]); + await Assert.That(errorHandled).IsFalse(); + await Assert.That(completed.Task.IsCompletedSuccessfully).IsTrue(); + } + } + + /// + /// Tests SubscribeSynchronous with onNext and onError. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task SubscribeSynchronous_WithOnNextAndOnError_HandlesError() + { + var subject = new Subject(); + var results = new List(); + var onNextCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var errorHandled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + subject.SubscribeSynchronous( + async v => + { + await Task.Yield(); + results.Add(v); + onNextCompleted.TrySetResult(); + }, + _ => errorHandled.TrySetResult()); + + subject.OnNext(1); + await onNextCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + subject.OnError(new InvalidOperationException()); + await errorHandled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + using (Assert.Multiple()) + { + await Assert.That(results).IsCollectionEqualTo([1]); + await Assert.That(errorHandled.Task.IsCompletedSuccessfully).IsTrue(); + } + } + + /// + /// Tests SubscribeSynchronous with onNext and onCompleted. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task SubscribeSynchronous_WithOnNextAndOnCompleted_CompletesCorrectly() + { + var subject = new Subject(); + var results = new List(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + subject.SubscribeSynchronous( + async v => + { + await Task.Yield(); + results.Add(v); + }, + () => completed.TrySetResult()); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + subject.OnCompleted(); + + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + using (Assert.Multiple()) + { + await Assert.That(results).IsCollectionEqualTo([1, SampleValue2]); + await Assert.That(completed.Task.IsCompletedSuccessfully).IsTrue(); + } + } + + /// + /// Tests SubscribeSynchronous with only onNext. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task SubscribeSynchronous_WithOnlyOnNext_ProcessesValues() + { + const int ExpectedCount = 3; + var subject = new Subject(); + var results = new List(); + var allReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + subject.SubscribeSynchronous( + async v => + { + await Task.Yield(); + results.Add(v); + _ = results.Count == ExpectedCount && allReceived.TrySetResult(); + }); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + subject.OnNext(SampleValue3); + + await allReceived.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + await Assert.That(results).IsCollectionEqualTo([1, SampleValue2, SampleValue3]); + } + + /// + /// Tests SubscribeAsync with onNext and onCompleted. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task SubscribeAsync_WithOnNextAndOnCompleted_CompletesCorrectly() + { + var results = new List(); + var completed = false; + var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var source = Observable.Create(observer => + { + observer.OnNext(1); + observer.OnNext(2); + observer.OnCompleted(); + return Disposable.Empty; + }); + + using var subscription = source.SubscribeAsync( + async v => + { + await Task.Delay(1); + results.Add(v); + }, + () => + { + completed = true; + completionSource.TrySetResult(true); + }); + + await completionSource.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + using (Assert.Multiple()) + { + await Assert.That(results).IsCollectionEqualTo([1, SampleValue2]); + await Assert.That(completed).IsTrue(); + } + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Throttle.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Throttle.cs new file mode 100644 index 0000000..2031a65 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Throttle.cs @@ -0,0 +1,409 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; +using ReactiveUI.Primitives.Async.Tests; +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// Tests for ReactiveExtensionsTests. +public partial class ReactiveExtensionsTests +{ + /// + /// Tests DebounceImmediate emits first immediately. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task DebounceImmediate_EmitsFirstImmediately() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + using var sub = subject.DebounceImmediate(TimeSpan.FromTicks(100), scheduler).Subscribe(results.Add); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + scheduler.AdvanceBy(SchedulerAdvancePastWindowTicks); + + await Assert.That(results).IsNotEmpty(); + await Assert.That(results[0]).IsEqualTo(1); + } + + /// + /// Tests ThrottleFirst emits first immediately, then ignores subsequent values within the throttle window. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task ThrottleFirst_EmitsFirstImmediately_IgnoresSubsequentWithinWindow() + { + var subject = new Subject(); + var results = new List(); + + // Throttle window of 100 ms + subject.ThrottleFirst(TimeSpan.FromMilliseconds(100)) + .Subscribe(results.Add); + + subject.OnNext(1); // Should be emitted immediately + subject.OnNext(SampleValue2); // Should be ignored (within throttle window) + subject.OnNext(SampleValue3); // Should be ignored (within throttle window) + await Task.Delay(ThrottleWaitMilliseconds); // Wait for throttle window to pass + subject.OnNext(SampleValue4); // Should be emitted + + // Verify results + await Assert.That(results).IsCollectionEqualTo([1, SampleValue4]); + } + + /// + /// Tests DropIfBusy drops values when busy. + /// + /// A representing the asynchronous RxVoid test. + [Test] + public async Task DropIfBusy_DropsWhenBusy() + { + var subject = new Subject(); + var results = new List(); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var processed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + subject.DropIfBusy(async x => + { + await release.Task; + results.Add(x); + processed.TrySetResult(); + }).Subscribe(); + + subject.OnNext(1); // Should process + subject.OnNext(SampleValue2); // Should drop + subject.OnNext(SampleValue3); // Should drop + + release.SetResult(new object()); // Complete the async action + await processed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + await Assert.That(results).IsCollectionEqualTo([1]); + } + + /// + /// Tests ThrottleDistinct throttles distinct values. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task ThrottleDistinct_ThrottlesDistinct() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + + subject.ThrottleDistinct(TimeSpan.FromTicks(100), scheduler) + .Subscribe(results.Add); + + subject.OnNext(1); + subject.OnNext(1); // Duplicate, ignored + subject.OnNext(SampleValue2); + scheduler.AdvanceBy(SchedulerAdvancePastWindowTicks); + subject.OnNext(SampleValue2); // Duplicate after throttle + + await Assert.That(results).IsCollectionEqualTo([SampleValue2]); + } + + /// + /// Tests DebounceUntil emits immediately when condition true, delays when false. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task DebounceUntil_EmitsImmediatelyWhenConditionTrue_DelaysWhenFalse() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + + subject.DebounceUntil(TimeSpan.FromTicks(100), x => x % SampleValue2 == 0, scheduler) + .Subscribe(results.Add); + + subject.OnNext(1); // Odd, should be delayed + scheduler.AdvanceBy(SchedulerHalfWindowTicks); // Advance less than debounce period + subject.OnNext(SampleValue2); // Even, should emit immediately, cancelling delayed 1 + scheduler.AdvanceBy(SchedulerWindowTicks); // Advance past debounce period + + await Assert.That(results).IsCollectionEqualTo([SampleValue2]); + } + + /// + /// Tests ThrottleOnScheduler throttles on the specified scheduler. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleOnScheduler_ThenThrottlesOnScheduler() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + + subject.ThrottleOnScheduler(TimeSpan.FromTicks(100), scheduler) + .Subscribe(results.Add); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + scheduler.AdvanceBy(SchedulerAdvancePastWindowTicks); + + await Assert.That(results).IsCollectionEqualTo([SampleValue2]); + } + + /// + /// Tests ThrottleDistinct with scheduler throttles and deduplicates. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleDistinctWithScheduler_ThenThrottlesAndDeduplicates() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + + subject.ThrottleDistinct(TimeSpan.FromTicks(100), scheduler) + .Subscribe(results.Add); + + subject.OnNext(1); + subject.OnNext(1); // Duplicate, suppressed by DistinctUntilChanged + subject.OnNext(SampleValue2); + scheduler.AdvanceBy(SchedulerAdvancePastWindowTicks); + + await Assert.That(results).IsCollectionEqualTo([SampleValue2]); + } + + /// + /// Tests DebounceImmediate flushes pending value when source errors. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDebounceImmediateSourceErrors_ThenFlushesAndForwardsError() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + Exception? observedError = null; + + subject.DebounceImmediate(TimeSpan.FromTicks(100), scheduler) + .Subscribe(results.Add, ex => observedError = ex); + + subject.OnNext(1); // Emitted immediately (first) + subject.OnNext(SampleValue2); // Buffered as pending + subject.OnError(new InvalidOperationException("test")); + + using (Assert.Multiple()) + { + await Assert.That(results).IsCollectionEqualTo([1, SampleValue2]); + await Assert.That(observedError).IsNotNull(); + } + } + + /// + /// Tests DebounceImmediate flushes pending value when source completes. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDebounceImmediateSourceCompletes_ThenFlushesAndCompletes() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + var completed = false; + + subject.DebounceImmediate(TimeSpan.FromTicks(100), scheduler) + .Subscribe(results.Add, () => completed = true); + + subject.OnNext(1); // Emitted immediately (first) + subject.OnNext(SampleValue2); // Buffered as pending + subject.OnCompleted(); + + using (Assert.Multiple()) + { + await Assert.That(results).IsCollectionEqualTo([1, SampleValue2]); + await Assert.That(completed).IsTrue(); + } + } + + /// + /// Tests DebounceUntil with scheduler delays non-matching values using the scheduler. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDebounceUntilWithScheduler_ThenUsesSchedulerForDelay() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + + subject.DebounceUntil(TimeSpan.FromTicks(100), x => x % SampleValue2 == 0, scheduler) + .Subscribe(results.Add); + + subject.OnNext(SampleValue2); // Even, emits immediately + subject.OnNext(1); // Odd, delayed + scheduler.AdvanceBy(SchedulerAdvancePastWindowTicks); + + await Assert.That(results).IsCollectionEqualTo([SampleValue2, 1]); + } + + /// + /// Tests ThrottleUntilTrue with predicate false path applies throttle. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleUntilTruePredicateFalse_ThenAppliesThrottle() + { + var subject = new Subject(); + var results = new List(); + var throttledArrived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = subject + .ThrottleUntilTrue(TimeSpan.FromMilliseconds(100), x => x > 5) + .Subscribe(value => + { + results.Add(value); + _ = value == 1 && throttledArrived.TrySetResult(value); + }); + + // Predicate true: immediate. + subject.OnNext(SampleValue10); + + // Predicate false: throttled — wait on the event instead of racing a fixed delay. + subject.OnNext(1); + await throttledArrived.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + await Assert.That(results).Contains(SampleValue10); + await Assert.That(results).Contains(1); + } + + /// + /// Tests ThrottleDistinct without scheduler parameter. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenThrottleDistinctWithoutScheduler_ThenThrottlesAndDeduplicates() + { + var subject = new Subject(); + var results = new List(); + + using var sub = subject + .ThrottleDistinct(TimeSpan.FromMilliseconds(200)) + .Subscribe(results.Add); + + subject.OnNext(1); + subject.OnNext(1); + subject.OnNext(SampleValue2); + + await AsyncTestHelpers.WaitForConditionAsync( + () => results.Contains(SampleValue2), + TimeSpan.FromSeconds(30)); + + await Assert.That(results).Contains(SampleValue2); + } + + /// + /// Tests DebounceUntil with scheduler delays non-matching values and passes matching immediately. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDebounceUntilWithScheduler_ThenUsesScheduler() + { + var scheduler = new VirtualClock(); + var subject = new Subject(); + var results = new List(); + + subject.DebounceUntil(TimeSpan.FromTicks(100), x => x % SampleValue2 == 0, scheduler) + .Subscribe(results.Add); + + subject.OnNext(SampleValue2); // condition true -> immediate + subject.OnNext(SampleValue3); // condition false -> delayed + scheduler.AdvanceBy(SchedulerWindowTicks + 1); + + await Assert.That(results).Contains(SampleValue2); + await Assert.That(results).Contains(SampleValue3); + } + + /// + /// Tests DebounceImmediate with null scheduler uses Default scheduler. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDebounceImmediateNullScheduler_ThenUsesDefault() + { + var subject = new Subject(); + var results = new List(); + + using var sub = subject + .DebounceImmediate(TimeSpan.FromMilliseconds(200)) + .Subscribe(results.Add); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + + await AsyncTestHelpers.WaitForConditionAsync( + () => results.Count >= 2, + TimeSpan.FromSeconds(30)); + + await Assert.That(results).Contains(1); + await Assert.That(results).Contains(SampleValue2); + } + + /// + /// Tests DebounceUntil without scheduler emits immediately when condition true. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDebounceUntilWithoutScheduler_ThenEmitsImmediatelyWhenConditionTrue() + { + var subject = new Subject(); + var results = new List(); + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var sub = subject.DebounceUntil(TimeSpan.FromMilliseconds(500), x => x % 2 == 0) + .Subscribe(v => + { + results.Add(v); + received.TrySetResult(); + }); + + // Even values should emit immediately (condition true) + subject.OnNext(SampleValue2); + + 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 VirtualClock(); + 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 VirtualClock(); + 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); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.WhereIsNotNullAndSignal.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.WhereIsNotNullAndSignal.cs new file mode 100644 index 0000000..8c7770c --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.WhereIsNotNullAndSignal.cs @@ -0,0 +1,199 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Linq; +using System.Reactive.Subjects; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// Tests for ReactiveExtensionsTests. +public partial class ReactiveExtensionsTests +{ + /// Alternating true/false source pattern (T,F,T,F,T) used by predicate tests. + private static readonly bool[] WhereIsNotNullSignalAlternatingTrueFalse = [true, false, true, false, true]; + + /// Expected sequence of first/second/third strings (nullable element type to match WhereIsNotNull source signature). + private static readonly string?[] ExpectedFirstSecondThirdNullable = ["first", "second", "third"]; + + /// + /// Tests the WhereIsNotNull extension. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task GivenNull_WhenWhereIsNotNull_ThenNoNotification() + { + // Given, When + bool? result = null; + using var disposable = Observable.Return(null).WhereIsNotNull().Subscribe(x => result = x); + + // Then + await Assert.That(result).IsNull(); + } + + /// + /// Tests the WhereIsNotNull extension. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task GivenValue_WhenWhereIsNotNull_ThenNotification() + { + // Given, When + bool? result = null; + using var disposable = Observable.Return(false).WhereIsNotNull().Subscribe(x => result = x); + + // Then + await Assert.That(result).IsFalse(); + } + + /// + /// Tests the AsSignal extension. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task GivenObservable_WhenAsSignal_ThenNotifiesUnit() + { + // Given, When + RxVoid? result = null; + using var disposable = Observable.Return(false).AsSignal().Subscribe(x => result = x); + + // Then + await Assert.That(result).IsEqualTo(RxVoid.Default); + } + + /// + /// Tests Not inverts boolean. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Not_InvertsBoolean() + { + var subject = new BehaviorSubject(true); + bool? result = null; + using var sub = subject.Not().Subscribe(x => result = x); + + await Assert.That(result).IsFalse(); + } + + /// + /// Tests WhereTrue filters to true values. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhereTrue_FiltersTrueValues() + { + var source = WhereIsNotNullSignalAlternatingTrueFalse.ToObservable(); + var results = new List(); + using var sub = source.WhereTrue().Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo([true, true, true]); + } + + /// + /// Tests WhereFalse filters to false values. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhereFalse_FiltersFalseValues() + { + var source = WhereIsNotNullSignalAlternatingTrueFalse.ToObservable(); + var results = new List(); + using var sub = source.WhereFalse().Subscribe(results.Add); + + await Assert.That(results).IsCollectionEqualTo([false, false]); + } + + /// + /// Tests WhereIsNotNull filters null values. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhereIsNotNull_FiltersNullValues() + { + var source = new[] { "a", null, "b", null, "c" }.ToObservable(); + var results = new List(); + using var sub = source.WhereIsNotNull().Subscribe(x => results.Add(x!)); + + await Assert.That(results).IsCollectionEqualTo(["a", "b", "c"]); + } + + /// + /// Tests AsSignal converts to RxVoid. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task AsSignal_ConvertsToUnit() + { + var source = Observable.Range(1, 3); + var results = new List(); + using var sub = source.AsSignal().Subscribe(results.Add); + + await Assert.That(results).Count().IsEqualTo(SampleValue3); + } + + /// + /// Tests WhereIsNotNull filtering nulls over time. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task WhereIsNotNull_FiltersNullsOverTime() + { + var subject = new Subject(); + var results = new List(); + + subject.WhereIsNotNull() + .Subscribe(results.Add); + + subject.OnNext("first"); + subject.OnNext(null); + subject.OnNext("second"); + subject.OnNext(null); + subject.OnNext("third"); + + // Only non-null values collected + await Assert.That(results).IsCollectionEqualTo(ExpectedFirstSecondThirdNullable); + } + + /// + /// Tests Not operator inverting boolean values over time. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task Not_InvertsBooleanValuesOverTime() + { + var subject = new Subject(); + var results = new List(); + + subject.Not() + .Subscribe(results.Add); + + subject.OnNext(true); + subject.OnNext(false); + subject.OnNext(true); + subject.OnNext(false); + + await Assert.That(results).IsCollectionEqualTo([false, true, false, true]); + } + + /// + /// Tests AsSignal converting values to RxVoid over time. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task AsSignal_ConvertsToUnitOverTime() + { + var subject = new Subject(); + var results = new List(); + + subject.AsSignal() + .Subscribe(results.Add); + + subject.OnNext(1); + subject.OnNext(SampleValue2); + subject.OnNext(SampleValue3); + + // All values converted to RxVoid.Default + await Assert.That(results).Count().IsEqualTo(SampleValue3); + await Assert.That(results).All(x => x == RxVoid.Default); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.cs new file mode 100644 index 0000000..265745c --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.cs @@ -0,0 +1,97 @@ +// 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.Primitives.Extensions.Tests; + +/// +/// Tests Reactive Extensions. +/// +public partial class ReactiveExtensionsTests +{ + /// Sample integer value (2) used by multiple tests. + private const int SampleValue2 = 2; + + /// Sample integer value (3) used by multiple tests. + private const int SampleValue3 = 3; + + /// Sample integer value (4) used by multiple tests. + private const int SampleValue4 = 4; + + /// Sample integer value (5) used by multiple tests. + private const int SampleValue5 = 5; + + /// Sample integer value (6) used by multiple tests. + private const int SampleValue6 = 6; + + /// Sample integer value (10) used by multiple tests. + private const int SampleValue10 = 10; + + /// Sample integer value (20) used by multiple tests. + private const int SampleValue20 = 20; + + /// Sample integer value (30) used by multiple tests. + private const int SampleValue30 = 30; + + /// Sample integer value (42) used by multiple tests. + private const int SampleValue42 = 42; + + /// Sample integer value (84) used by multiple tests. + private const int SampleValue84 = 84; + + /// Sample integer value (7) used by multiple tests. + private const int SampleValue7 = 7; + + /// Sample integer value (8) used by multiple tests. + private const int SampleValue8 = 8; + + /// Sample integer value (9) used by multiple tests. + private const int SampleValue9 = 9; + + /// Sample integer value (11) used by multiple tests. + private const int SampleValue11 = 11; + + /// Sample integer value (13) used by multiple tests. + private const int SampleValue13 = 13; + + /// Sample integer value (15) used by multiple tests. + private const int SampleValue15 = 15; + + /// Sample integer value (99) used by multiple tests. + private const int SampleValue99 = 99; + + /// Minimum-elapsed-time guard in milliseconds for timing assertions. + private const int MinimumExpectedMilliseconds = 90; + + /// Throttle/debounce wait window in milliseconds. + private const int ThrottleWaitMilliseconds = 150; + + /// Settle delay in milliseconds used by buffer/foreach tests. + private const int SettleDelayMilliseconds = 200; + + /// Half of the scheduler window in virtual ticks. + private const int SchedulerHalfWindowTicks = 50; + + /// Scheduler window length in virtual ticks. + private const int SchedulerWindowTicks = 100; + + /// One tick past the scheduler window, used to advance virtual time. + private const int SchedulerAdvancePastWindowTicks = 101; + + /// Short real-time delay in milliseconds. + private const int ShortDelayMilliseconds = 500; + + /// Long real-time delay in milliseconds. + private const int LongDelayMilliseconds = 1_000; + + /// Expected sequence [1, 2, 3] for collection equality assertions. + private static readonly int[] ExpectedSequence123 = [1, 2, 3]; + +#if NET9_0_OR_GREATER + /// Lock used to synchronize observer callbacks during concurrency tests. + private readonly Lock _gate = new(); +#else + /// Lock used to synchronize observer callbacks during concurrency tests. + private readonly object _gate = new(); +#endif +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveUI.Primitives.Extensions.Tests.csproj b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveUI.Primitives.Extensions.Tests.csproj new file mode 100644 index 0000000..40854a2 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveUI.Primitives.Extensions.Tests.csproj @@ -0,0 +1,47 @@ + + + + $(TestTargetFrameworks) + false + Exe + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/VirtualClockTestExtensions.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/VirtualClockTestExtensions.cs new file mode 100644 index 0000000..4dbc28b --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/VirtualClockTestExtensions.cs @@ -0,0 +1,29 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// +/// Compatibility helpers for tests migrated from Microsoft.Reactive.Testing. +/// +internal static class VirtualClockTestExtensions +{ + /// + /// Advances a virtual clock by ticks. + /// + /// The virtual clock. + /// The number of ticks to advance. + public static void AdvanceBy(this VirtualClock clock, long ticks) => + clock.AdvanceBy(TimeSpan.FromTicks(ticks)); + + /// + /// Advances a virtual clock to an absolute tick value. + /// + /// The virtual clock. + /// The absolute tick value. + public static void AdvanceTo(this VirtualClock clock, long ticks) => + clock.AdvanceTo(new DateTimeOffset(ticks, TimeSpan.Zero)); +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/ApiApprovalTests.Primitives.DotNet10_0.verified.txt b/src/tests/ReactiveUI.Primitives.Tests/ApiApprovalTests.Primitives.DotNet10_0.verified.txt index 58e9b65..bed1b27 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ApiApprovalTests.Primitives.DotNet10_0.verified.txt +++ b/src/tests/ReactiveUI.Primitives.Tests/ApiApprovalTests.Primitives.DotNet10_0.verified.txt @@ -1,11 +1,7 @@ [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Async.Tests")] -[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Blazor")] [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Blazor.Tests")] -[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Maui")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Extensions.Tests")] [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Tests")] -[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.WinForms")] -[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.WinUI")] -[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Wpf")] namespace ReactiveUI.Primitives { public sealed class AllPredicateObserver : ReactiveUI.Primitives.BooleanTerminalObserver @@ -742,9 +738,11 @@ namespace ReactiveUI.Primitives.Core } namespace ReactiveUI.Primitives.Disposables { - public sealed class AnonymousDisposable : System.IDisposable + [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] + public sealed class ActionDisposable : ReactiveUI.Primitives.Disposables.IsDisposed, System.IDisposable { - public AnonymousDisposable(System.Action dispose) { } + public ActionDisposable(System.Action action) { } + public bool IsDisposed { get; } public void Dispose() { } } [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] @@ -776,9 +774,20 @@ namespace ReactiveUI.Primitives.Disposables public static System.IDisposable Empty { get; } public static System.IDisposable Create(System.Action dispose) { } } + [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] + public sealed class DisposableBag : ReactiveUI.Primitives.Disposables.IsDisposed, System.IDisposable + { + public DisposableBag() { } + public DisposableBag(System.IDisposable first, System.IDisposable second) { } + public DisposableBag(System.IDisposable first, System.IDisposable second, System.IDisposable third) { } + public bool IsDisposed { get; } + public void Add(System.IDisposable disposable) { } + public void Dispose() { } + } + [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class EmptyDisposable : System.IDisposable { - public EmptyDisposable() { } + public static ReactiveUI.Primitives.Disposables.EmptyDisposable Instance { get; } public void Dispose() { } } public interface IsDisposed : System.IDisposable @@ -800,6 +809,23 @@ namespace ReactiveUI.Primitives.Disposables public static System.IDisposable Create(params System.IDisposable[] disposables) { } } [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] + public sealed class MutableDisposable : ReactiveUI.Primitives.Disposables.IsDisposed, System.IDisposable + { + public MutableDisposable() { } + public System.IDisposable? Disposable { get; set; } + public bool IsDisposed { get; } + public void Dispose() { } + } + [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] + public sealed class OnceDisposable : ReactiveUI.Primitives.Disposables.IsDisposed, System.IDisposable + { + public OnceDisposable() { } + public System.IDisposable? Disposable { get; set; } + public bool IsAssigned { get; } + public bool IsDisposed { get; } + public void Dispose() { } + } + [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class Pocket : ReactiveUI.Primitives.Disposables.MultipleDisposable { public Pocket() { } @@ -839,6 +865,14 @@ namespace ReactiveUI.Primitives.Disposables public Slot(System.IDisposable disposable) { } public Slot(System.IDisposable disposable, System.Action? action) { } } + [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] + public sealed class SwapDisposable : ReactiveUI.Primitives.Disposables.IsDisposed, System.IDisposable + { + public SwapDisposable() { } + public System.IDisposable? Disposable { get; set; } + public bool IsDisposed { get; } + public void Dispose() { } + } } namespace ReactiveUI.Primitives.Signals { diff --git a/src/tests/ReactiveUI.Primitives.Tests/ApiApprovalTests.Primitives.DotNet8_0.verified.txt b/src/tests/ReactiveUI.Primitives.Tests/ApiApprovalTests.Primitives.DotNet8_0.verified.txt index 58e9b65..bed1b27 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ApiApprovalTests.Primitives.DotNet8_0.verified.txt +++ b/src/tests/ReactiveUI.Primitives.Tests/ApiApprovalTests.Primitives.DotNet8_0.verified.txt @@ -1,11 +1,7 @@ [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Async.Tests")] -[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Blazor")] [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Blazor.Tests")] -[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Maui")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Extensions.Tests")] [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Tests")] -[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.WinForms")] -[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.WinUI")] -[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Wpf")] namespace ReactiveUI.Primitives { public sealed class AllPredicateObserver : ReactiveUI.Primitives.BooleanTerminalObserver @@ -742,9 +738,11 @@ namespace ReactiveUI.Primitives.Core } namespace ReactiveUI.Primitives.Disposables { - public sealed class AnonymousDisposable : System.IDisposable + [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] + public sealed class ActionDisposable : ReactiveUI.Primitives.Disposables.IsDisposed, System.IDisposable { - public AnonymousDisposable(System.Action dispose) { } + public ActionDisposable(System.Action action) { } + public bool IsDisposed { get; } public void Dispose() { } } [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] @@ -776,9 +774,20 @@ namespace ReactiveUI.Primitives.Disposables public static System.IDisposable Empty { get; } public static System.IDisposable Create(System.Action dispose) { } } + [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] + public sealed class DisposableBag : ReactiveUI.Primitives.Disposables.IsDisposed, System.IDisposable + { + public DisposableBag() { } + public DisposableBag(System.IDisposable first, System.IDisposable second) { } + public DisposableBag(System.IDisposable first, System.IDisposable second, System.IDisposable third) { } + public bool IsDisposed { get; } + public void Add(System.IDisposable disposable) { } + public void Dispose() { } + } + [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class EmptyDisposable : System.IDisposable { - public EmptyDisposable() { } + public static ReactiveUI.Primitives.Disposables.EmptyDisposable Instance { get; } public void Dispose() { } } public interface IsDisposed : System.IDisposable @@ -800,6 +809,23 @@ namespace ReactiveUI.Primitives.Disposables public static System.IDisposable Create(params System.IDisposable[] disposables) { } } [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] + public sealed class MutableDisposable : ReactiveUI.Primitives.Disposables.IsDisposed, System.IDisposable + { + public MutableDisposable() { } + public System.IDisposable? Disposable { get; set; } + public bool IsDisposed { get; } + public void Dispose() { } + } + [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] + public sealed class OnceDisposable : ReactiveUI.Primitives.Disposables.IsDisposed, System.IDisposable + { + public OnceDisposable() { } + public System.IDisposable? Disposable { get; set; } + public bool IsAssigned { get; } + public bool IsDisposed { get; } + public void Dispose() { } + } + [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class Pocket : ReactiveUI.Primitives.Disposables.MultipleDisposable { public Pocket() { } @@ -839,6 +865,14 @@ namespace ReactiveUI.Primitives.Disposables public Slot(System.IDisposable disposable) { } public Slot(System.IDisposable disposable, System.Action? action) { } } + [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] + public sealed class SwapDisposable : ReactiveUI.Primitives.Disposables.IsDisposed, System.IDisposable + { + public SwapDisposable() { } + public System.IDisposable? Disposable { get; set; } + public bool IsDisposed { get; } + public void Dispose() { } + } } namespace ReactiveUI.Primitives.Signals { diff --git a/src/tests/ReactiveUI.Primitives.Tests/ApiApprovalTests.Primitives.DotNet9_0.verified.txt b/src/tests/ReactiveUI.Primitives.Tests/ApiApprovalTests.Primitives.DotNet9_0.verified.txt index 58e9b65..bed1b27 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ApiApprovalTests.Primitives.DotNet9_0.verified.txt +++ b/src/tests/ReactiveUI.Primitives.Tests/ApiApprovalTests.Primitives.DotNet9_0.verified.txt @@ -1,11 +1,7 @@ [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Async.Tests")] -[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Blazor")] [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Blazor.Tests")] -[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Maui")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Extensions.Tests")] [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Tests")] -[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.WinForms")] -[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.WinUI")] -[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Wpf")] namespace ReactiveUI.Primitives { public sealed class AllPredicateObserver : ReactiveUI.Primitives.BooleanTerminalObserver @@ -742,9 +738,11 @@ namespace ReactiveUI.Primitives.Core } namespace ReactiveUI.Primitives.Disposables { - public sealed class AnonymousDisposable : System.IDisposable + [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] + public sealed class ActionDisposable : ReactiveUI.Primitives.Disposables.IsDisposed, System.IDisposable { - public AnonymousDisposable(System.Action dispose) { } + public ActionDisposable(System.Action action) { } + public bool IsDisposed { get; } public void Dispose() { } } [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] @@ -776,9 +774,20 @@ namespace ReactiveUI.Primitives.Disposables public static System.IDisposable Empty { get; } public static System.IDisposable Create(System.Action dispose) { } } + [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] + public sealed class DisposableBag : ReactiveUI.Primitives.Disposables.IsDisposed, System.IDisposable + { + public DisposableBag() { } + public DisposableBag(System.IDisposable first, System.IDisposable second) { } + public DisposableBag(System.IDisposable first, System.IDisposable second, System.IDisposable third) { } + public bool IsDisposed { get; } + public void Add(System.IDisposable disposable) { } + public void Dispose() { } + } + [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class EmptyDisposable : System.IDisposable { - public EmptyDisposable() { } + public static ReactiveUI.Primitives.Disposables.EmptyDisposable Instance { get; } public void Dispose() { } } public interface IsDisposed : System.IDisposable @@ -800,6 +809,23 @@ namespace ReactiveUI.Primitives.Disposables public static System.IDisposable Create(params System.IDisposable[] disposables) { } } [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] + public sealed class MutableDisposable : ReactiveUI.Primitives.Disposables.IsDisposed, System.IDisposable + { + public MutableDisposable() { } + public System.IDisposable? Disposable { get; set; } + public bool IsDisposed { get; } + public void Dispose() { } + } + [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] + public sealed class OnceDisposable : ReactiveUI.Primitives.Disposables.IsDisposed, System.IDisposable + { + public OnceDisposable() { } + public System.IDisposable? Disposable { get; set; } + public bool IsAssigned { get; } + public bool IsDisposed { get; } + public void Dispose() { } + } + [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class Pocket : ReactiveUI.Primitives.Disposables.MultipleDisposable { public Pocket() { } @@ -839,6 +865,14 @@ namespace ReactiveUI.Primitives.Disposables public Slot(System.IDisposable disposable) { } public Slot(System.IDisposable disposable, System.Action? action) { } } + [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] + public sealed class SwapDisposable : ReactiveUI.Primitives.Disposables.IsDisposed, System.IDisposable + { + public SwapDisposable() { } + public System.IDisposable? Disposable { get; set; } + public bool IsDisposed { get; } + public void Dispose() { } + } } namespace ReactiveUI.Primitives.Signals { diff --git a/src/tests/ReactiveUI.Primitives.Tests/PublicApiBehaviorTests.cs b/src/tests/ReactiveUI.Primitives.Tests/PublicApiBehaviorTests.cs index 8643b0b..c3ba79a 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/PublicApiBehaviorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/PublicApiBehaviorTests.cs @@ -670,7 +670,7 @@ public void SparksCoverValueErrorCompletionEqualityAndAcceptOverloads() Assert.True(next.ToString().Contains(FortyTwo.ToString(), StringComparison.Ordinal)); Assert.Equal(next.GetHashCode(), sameNext.GetHashCode()); next.Accept((IObserver)observer); - Assert.Equal("next:42", next.Accept((IObserver)observer)); + Assert.Equal("next:42", next.Accept((IObserver)observer)); next.Accept(value => observer.Events.Add("delegate-next:" + value), ex => observer.Events.Add(ex.Message), () => observer.Events.Add("delegate-completed")); Assert.Equal("fn-next:42", next.Accept(value => "fn-next:" + value, ex => ex.Message, () => FunctionCompletedText)); Assert.Throws(() => next.Accept((IObserver)null!)); @@ -678,7 +678,7 @@ public void SparksCoverValueErrorCompletionEqualityAndAcceptOverloads() Assert.Throws(() => next.Accept(null!, ex => { }, () => { })); Assert.Throws(() => next.Accept(value => { }, null!, () => { })); Assert.Throws(() => next.Accept(value => { }, ex => { }, null!)); - Assert.Throws(() => next.Accept(null!, ex => ex.Message, () => "done")); + Assert.Throws(() => next.Accept(null!, ex => ex.Message, () => "done")); Assert.Throws(() => next.Accept(value => value.ToString(), null!, () => "done")); Assert.Throws(() => next.Accept(value => value.ToString(), ex => ex.Message, null!)); @@ -700,7 +700,7 @@ public void SparksCoverValueErrorCompletionEqualityAndAcceptOverloads() Assert.Throws(() => errorSpark.Accept(null!, ex => { }, () => { })); Assert.Throws(() => errorSpark.Accept(value => { }, null!, () => { })); Assert.Throws(() => errorSpark.Accept(value => { }, ex => { }, null!)); - Assert.Throws(() => errorSpark.Accept(null!, ex => ex.Message, () => "done")); + Assert.Throws(() => errorSpark.Accept(null!, ex => ex.Message, () => "done")); Assert.Throws(() => errorSpark.Accept(value => value.ToString(), null!, () => "done")); Assert.Throws(() => errorSpark.Accept(value => value.ToString(), ex => ex.Message, null!)); @@ -719,7 +719,7 @@ public void SparksCoverValueErrorCompletionEqualityAndAcceptOverloads() Assert.Throws(() => completed.Accept(null!, ex => { }, () => { })); Assert.Throws(() => completed.Accept(value => { }, null!, () => { })); Assert.Throws(() => completed.Accept(value => { }, ex => { }, null!)); - Assert.Throws(() => completed.Accept(null!, ex => ex.Message, () => "done")); + Assert.Throws(() => completed.Accept(null!, ex => ex.Message, () => "done")); Assert.Throws(() => completed.Accept(value => value.ToString(), null!, () => "done")); Assert.Throws(() => completed.Accept(value => value.ToString(), ex => ex.Message, null!)); Assert.Throws(() => completed.ToObservable(null!)); diff --git a/src/tests/ReactiveUI.Primitives.Tests/ReactiveUI.Primitives.Tests.csproj b/src/tests/ReactiveUI.Primitives.Tests/ReactiveUI.Primitives.Tests.csproj index c4e5dd8..f5db6d2 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ReactiveUI.Primitives.Tests.csproj +++ b/src/tests/ReactiveUI.Primitives.Tests/ReactiveUI.Primitives.Tests.csproj @@ -21,6 +21,7 @@ + @@ -38,6 +39,7 @@ + diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFromTaskTest.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFromTaskTest.cs index e38f3e6..98c67e3 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFromTaskTest.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFromTaskTest.cs @@ -31,6 +31,14 @@ public class SignalFromTaskTest /// private const int CleanupDelayMilliseconds = 5000; + /// + /// Time spent performing synchronous cancellation cleanup work. Kept short so the + /// blocking does not occupy a thread-pool thread long + /// enough to delay the timer-driven cancellation callbacks the tests wait on (which + /// previously tipped the loaded CI runners over ). + /// + private const int CleanupWorkMilliseconds = 250; + /// /// Delay used to wait for cancellation cleanup to finish. /// @@ -601,7 +609,7 @@ private static void RecordStatus(StatusTrail statusTrail, ref int position, stri private static void RecordCancellationCleanup(StatusTrail statusTrail, ref int position) { RecordStatus(statusTrail, ref position, StartingCancellingCommand); - Thread.Sleep(CleanupDelayMilliseconds); + Thread.Sleep(CleanupWorkMilliseconds); RecordStatus(statusTrail, ref position, FinishedCancellingCommand); } diff --git a/src/tests/Shared/ApiApprovalExtensions.cs b/src/tests/Shared/ApiApprovalExtensions.cs index c704c98..f040cba 100644 --- a/src/tests/Shared/ApiApprovalExtensions.cs +++ b/src/tests/Shared/ApiApprovalExtensions.cs @@ -27,7 +27,13 @@ public static async Task CheckApproval( [CallerFilePath] string filePath = "") { var generatorOptions = new ApiGeneratorOptions(); - var apiText = assembly.GeneratePublicApi(generatorOptions); + + // Normalise line endings so the approval is resilient to CRLF/LF differences between + // the checked-in baseline (committed via .gitattributes as CRLF) and the value produced + // on whichever platform the tests run (LF on Linux/macOS, CRLF on Windows). + var apiText = assembly.GeneratePublicApi(generatorOptions) + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n'); _ = await Verifier.Verify(apiText, null, filePath) .UniqueForRuntimeAndVersion() .ScrubEmptyLines() diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncTestHelpers.cs b/src/tests/Shared/AsyncTestHelpers.cs similarity index 100% rename from src/tests/ReactiveUI.Primitives.Async.Tests/AsyncTestHelpers.cs rename to src/tests/Shared/AsyncTestHelpers.cs