diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index dbc7e09..81dec63 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -21,10 +21,17 @@ │ ├── IterationGuard.fs – internal guard that stops `iterAsync` and reports its failures │ ├── AsyncObservable.fs – Async flavour (cold `Async` terminals, `mapAsync`, `ofAsync`) │ └── TaskObservable.fs – Task flavour (hot `Task` terminals, `mapAsync`, `ofTask`) -├── tests/FSharp.Control.R3.Tests/ – MSTest test project +├── tests/FSharp.Control.R3.Tests/ – MSTest integration test project │ ├── TestCategories.fs – test category attributes for `--filter TestCategory=...` -│ ├── BuilderTests.fs – builder behavior tests -│ └── ObservableTests.fs – observable behavior tests +│ ├── TestHelpers.fs – deterministic sources, recorder, probes, gated selectors +│ ├── ProcessingOptionsTests.fs – processing options +│ ├── ObservableTests.fs – observable operators and factories +│ ├── ChunkTests.fs – chunking with `FakeTimeProvider` +│ ├── BuilderTests.fs – `rxquery` builder +│ ├── AsyncObservableTests.fs – Async flavour +│ ├── TaskObservableTests.fs – Task flavour +│ ├── MapAsyncTests.fs – `mapAsync` per await operation, both flavours +│ └── IntegrationTests.fs – end-to-end scenarios across the library ├── build/ – FAKE build scripts and release automation └── docsSrc/ – FSharp.Formatting documentation source ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 84ad714..129b082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Integration tests covering every public function against real R3 pipelines ([#11](https://github.com/fsprojects/FSharp.Control.R3/issues/11)) - `Observable.choose` as a function of the `Observable` module; like every function of the library that works with optional values it takes a `voption` chooser, and the `option` variant is `ObservableOption.choose` - `Observable.ofSeq (items)` and `Observable.ofSeq (items, cancellationToken)`, reachable after `open FSharp.Control.R3` - `Observable.chunkByBoundaries` accepting window boundaries of any element type diff --git a/Directory.Packages.props b/Directory.Packages.props index f9f86a2..a2d90c6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,6 +14,7 @@ + diff --git a/tests/FSharp.Control.R3.Tests/AsyncObservableTests.fs b/tests/FSharp.Control.R3.Tests/AsyncObservableTests.fs new file mode 100644 index 0000000..8b5dbc1 --- /dev/null +++ b/tests/FSharp.Control.R3.Tests/AsyncObservableTests.fs @@ -0,0 +1,837 @@ +namespace FSharp.Control.R3.Tests + +open System +open System.Collections.Concurrent +open System.Linq +open System.Threading +open System.Threading.Tasks +open Microsoft.VisualStudio.TestTools.UnitTesting +open R3 +open FSharp.Control.R3 +open FSharp.Control.R3.Async +open FSharp.Control.R3.Tests.TestHelpers + +/// +/// Integration tests of the cold Async functions of and +/// against R3. +/// +/// The processing modes of +/// +/// are covered in MapAsyncTests.fs. +/// +/// +[] +type AsyncObservableTests (testContext : TestContext) = + + // Typed as exn so that Assert.AreSame accepts it next to the InvalidOperationException that Assert.ThrowsAsync returns + let boom : exn = InvalidOperationException "boom" + + // A synchronous source that completes without elements + let empty () : Observable = Sources.values [||] + + // Starts the computation on the calling thread with the test token, so the test timeout cancels a computation that hangs + let run (computation : Async<'T>) = AsyncTest.start testContext.CancellationToken computation + + // Elements that leave the result of the named terminal function undecided: firstAsync and existsAsync decide at the + // first element, all (x > 0) decides early only at an element that is not positive, the others wait for the completion + let undecidedElements (functionName : string) = + match functionName with + | "existsAsync" + | "firstAsync" -> [||] + | _ -> [| 1; 2 |] + + /// Names of the terminal functions, as the data rows of the tests that cover every one of them. + static member TerminalFunctions : obj array seq = dataRows Terminals.names + + // length + + [] + member _.``length subscribes when the Async starts and counts only the elements pushed after that`` () : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + let lengthOfSubject = subject |> probe.Watch |> Observable.length + + // Building the computation does not subscribe, and a subject drops the elements it receives without subscribers + subject.OnNext 1 + Assert.AreEqual (0, probe.Subscribed, "Building the computation must not subscribe to the source") + + let lengthTask = run lengthOfSubject + Assert.AreEqual (1, probe.Subscribed, "Starting the computation must subscribe to the source") + subject.OnNext 2 + subject.OnNext 3 + Assert.IsFalse (lengthTask.IsCompleted, "length must wait for the source to complete") + subject.OnCompleted (Result.Success) + + let! count = lengthTask + Assert.AreEqual (2, count, "length must count only the elements pushed after the computation started") + } + + [] + member _.``length of a subject that completed before the Async started is zero`` () : Task = task { + use subject = new Subject () + subject.OnNext 1 + subject.OnCompleted (Result.Success) + + // Subscribing to a completed subject delivers only its completion, during the subscription + let! count = run (Observable.length subject) + + Assert.AreEqual (0, count, "length must not count the elements that a subject emitted before the computation started") + } + + [] + member _.``length subscribes to the source once on every run of the same Async`` () : Task = task { + let probe = SubscriptionProbe () + let lengthOfSource = + Sources.values [| 1; 2; 3 |] + |> probe.Watch + |> Observable.length + + let! first = run lengthOfSource + let! second = run lengthOfSource + + Assert.AreEqual (3, first, "The first run must count every element") + Assert.AreEqual (3, second, "The second run must count every element again") + Assert.AreEqual (2, probe.Subscribed, "Every run of the computation must subscribe to the source once") + } + + // aggregate + + [] + member _.``aggregate folds the elements in order starting from the seed`` () : Task = task { + let source = Sources.values [| 1; 2; 3; 4 |] + + let! sum = run (source |> Observable.aggregate 0 (+)) + let! digits = + run ( + source + |> Observable.aggregate "" (fun digits x -> $"%s{digits}%d{x}") + ) + + Assert.AreEqual (10, sum, "aggregate must add every element to the seed") + Assert.AreEqual ("1234", digits, "aggregate must pass the elements to the accumulator in their order") + } + + [] + member _.``aggregate returns the seed for an empty source`` () : Task = task { + let! result = run (empty () |> Observable.aggregate 42 (+)) + Assert.AreEqual (42, result, "aggregate must return the seed when the source has no element") + } + + // all + + [] + member _.``all returns true for an empty source`` () : Task = task { + // R3 completes AllAsync with true when the source completes without an element that fails the predicate + let! result = run (empty () |> Observable.all (fun _ -> false)) + Assert.IsTrue (result, "all must be true for a source without elements, whatever the predicate") + } + + [] + member _.``all returns true when every element satisfies the predicate`` () : Task = task { + let! result = + run ( + Sources.values [| 1; 2; 3 |] + |> Observable.all (fun x -> x > 0) + ) + Assert.IsTrue (result, "all must be true when every element satisfies the predicate") + } + + [] + member _.``all returns false at the first element that fails the predicate and unsubscribes`` () : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + let allTask = + subject + |> probe.Watch + |> Observable.all (fun x -> x > 0) + |> run + + subject.OnNext 1 + Assert.IsFalse (allTask.IsCompleted, "all must wait while every element satisfies the predicate") + // R3 decides at the first element that fails the predicate and disposes its subscription without waiting for completion + subject.OnNext (-1) + Assert.AreEqual (1, probe.Disposed, "all must unsubscribe from the source as soon as the result is decided") + + let! result = allTask + Assert.IsFalse (result, "all must be false once an element fails the predicate") + } + + // existsAsync + + [] + member _.``existsAsync returns false for an empty source`` () : Task = task { + let! result = run (Observable.existsAsync (empty ())) + Assert.IsFalse (result, "existsAsync must be false for a source without elements") + } + + [] + member _.``existsAsync returns true at the first element and unsubscribes`` () : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + let existsTask = subject |> probe.Watch |> Observable.existsAsync |> run + + Assert.IsFalse (existsTask.IsCompleted, "existsAsync must wait for an element or for the completion") + // R3 decides AnyAsync at the first element and disposes its subscription without waiting for completion + subject.OnNext 5 + Assert.AreEqual (1, probe.Disposed, "existsAsync must unsubscribe from the source as soon as an element arrives") + + let! result = existsTask + Assert.IsTrue (result, "existsAsync must be true once an element arrives") + } + + // firstAsync + + [] + member _.``firstAsync returns the first element and unsubscribes`` () : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + let firstTask = subject |> probe.Watch |> Observable.firstAsync |> run + + // R3 decides FirstAsync at the first element and disposes its subscription, so later elements are not observed + subject.OnNext 7 + Assert.AreEqual (1, probe.Disposed, "firstAsync must unsubscribe from the source at the first element") + subject.OnNext 8 + + let! first = firstTask + Assert.AreEqual (7, first, "firstAsync must return the first element") + } + + [] + member _.``firstAsync of an empty source raises InvalidOperationException rather than an AggregateException`` () : Task = task { + // R3 faults FirstAsync with InvalidOperationException for a source without elements; the Async flavour raises that + // exception itself, where Async.AwaitTask would raise the AggregateException of the faulted task + do! + Assert.ThrowsExactlyAsync( + (fun () -> run (Observable.firstAsync (empty ())) :> Task), + "firstAsync must raise exactly InvalidOperationException for a source without elements" + ) + :> Task + } + + [] + member _.``firstAsync of an empty source raises an exception that a typed handler in an async workflow catches`` () : Task = task { + let firstOrFallback = async { + try + let! first = Observable.firstAsync (empty ()) + return $"first element %d{first}" + with :? InvalidOperationException -> + return "handled" + } + + let! outcome = run firstOrFallback + + // A handler for InvalidOperationException only matches when the computation raises that exception unwrapped + Assert.AreEqual ("handled", outcome, "The typed handler must catch the exception that firstAsync raises") + } + + // iter + + [] + member _.``iter invokes the action for every element in order`` () : Task = task { + let seen = ResizeArray() + + do! run (Sources.values [| 1; 2; 3 |] |> Observable.iter seen.Add) + + CollectionAssert.AreEqual ([| 1; 2; 3 |], seen.ToArray (), "iter must invoke the action for every element in order") + } + + [] + member _.``iter completes without invoking the action for an empty source`` () : Task = task { + let seen = ResizeArray() + + do! run (empty () |> Observable.iter seen.Add) + + Assert.IsEmpty (seen, "iter must not invoke the action when the source has no element") + } + + [] + member _.``iter raises the exception of the action as the same instance and stops processing`` () : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + let invoked = ResizeArray() + + let action x = + invoked.Add x + if x = 2 then + raise boom + + let iterTask = subject |> probe.Watch |> Observable.iter action |> run + subject.OnNext 1 + // R3 turns the exception of the action into a fault of ForEachAsync, which disposes its subscription at once + subject.OnNext 2 + Assert.AreEqual (1, probe.Disposed, "iter must unsubscribe from the source when the action fails") + subject.OnNext 3 + + let! error = + Assert.ThrowsAsync((fun () -> iterTask :> Task), "iter must raise the exception of the action") + Assert.AreSame (boom, error, "iter must raise the exception of the action itself, not a wrapper") + CollectionAssert.AreEqual ([| 1; 2 |], invoked.ToArray (), "The action must not be invoked after it failed") + } + + // toArray and toList + + [] + member _.``toArray collects every element in order`` () : Task = task { + let! elements = run (Observable.toArray (Sources.values [| 1; 2; 3 |])) + CollectionAssert.AreEqual ([| 1; 2; 3 |], elements, "toArray must collect every element in order") + } + + [] + member _.``toList collects every element in order`` () : Task = task { + let! elements = run (Observable.toList (Sources.values [| 1; 2; 3 |])) + Assert.AreEqual([ 1; 2; 3 ], elements, "toList must collect every element in order") + } + + [] + member _.``toArray and toList return empty collections for an empty source`` () : Task = task { + let! collectedArray = run (Observable.toArray (empty ())) + let! collectedList = run (Observable.toList (empty ())) + + Assert.IsEmpty (collectedArray, "toArray must return an empty array for a source without elements") + Assert.IsEmpty (collectedList, "toList must return an empty list for a source without elements") + } + + // ofAsync + + [] + member _.``ofAsync emits the result of an immediate computation once and completes synchronously`` () = + // FromAsync awaits the task of the computation, which an immediate computation has already completed, + // so the result and the completion arrive during the subscription + use recorder = Observable.ofAsync (async { return 7 }) |> Recorder.Attach + + CollectionAssert.AreEqual ([| 7 |], recorder.Values, "ofAsync must emit the result of the computation once") + Assert.IsEmpty (recorder.Errors, "ofAsync must not report errors for a successful computation") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "ofAsync must complete successfully during the subscription") + + [] + member _.``ofAsync starts the computation once per subscription`` () = + let runs = ref 0 + + let numberOfRun = + Observable.ofAsync ( + async { + runs.Value <- runs.Value + 1 + return runs.Value + } + ) + + Assert.AreEqual (0, runs.Value, "Creating the sequence must not start the computation") + use first = Recorder.Attach numberOfRun + use second = Recorder.Attach numberOfRun + + Assert.AreEqual (2, runs.Value, "Every subscription must start the computation once") + CollectionAssert.AreEqual ([| 1 |], first.Values, "The first subscription must receive the result of the first run") + CollectionAssert.AreEqual ([| 2 |], second.Values, "The second subscription must receive the result of its own run") + + [] + member _.``ofAsync completes with the failure of the computation as the same exception`` () = + // FromAsync turns a fault of the factory task into OnCompleted(Failure), and awaiting the task of the computation + // rethrows the exception that the computation raised + use recorder = + Observable.ofAsync (async { return (raise boom : int) }) + |> Recorder.Attach + + Assert.IsEmpty (recorder.Values, "A failed computation must not emit a value") + Assert.IsEmpty (recorder.Errors, "The failure must not be reported through OnErrorResume") + assertFailedWith boom recorder "ofAsync must complete with the exception of the computation itself" + + [] + member _.``disposing an ofAsync subscription cancels the computation`` () = + let token = ref CancellationToken.None + let resume = ref (fun (_ : int) -> ()) + + let computation = async { + let! cancellationToken = Async.CancellationToken + token.Value <- cancellationToken + // Suspends until the test resumes it, like a computation that does not observe its token + return! Async.FromContinuations (fun (onSuccess, _, _) -> resume.Value <- onSuccess) + } + + use recorder = Observable.ofAsync computation |> Recorder.Attach + Assert.IsTrue (token.Value.CanBeCanceled, "Subscribing must start the computation with the token of the subscription") + Assert.IsFalse (token.Value.IsCancellationRequested, "The token must not be cancelled while the subscription is alive") + Assert.IsEmpty (recorder.Values, "Nothing may be emitted while the computation is running") + Assert.IsTrue (recorder.Completion.IsNone, "The sequence must not complete while the computation is running") + + // The subscription of FromAsync is a CancellationDisposable whose token the computation runs with + recorder.Dispose () + Assert.IsTrue (token.Value.IsCancellationRequested, "Disposing the subscription must cancel the token of the computation") + + // R3's Observer ignores every notification once it is disposed, so a late result can never reach a subscriber; + // resuming only runs the abandoned continuation and nothing is asserted about it + let onSuccess = resume.Value + onSuccess 42 + + // iterAsync + + [] + member _.``iterAsync applies the action to every element in order and completes with the source`` () : Task = task { + let seen = ConcurrentQueue() + + do! + Sources.values [| 1; 2; 3 |] + |> Observable.iterAsync ProcessingOptions.Default (fun x -> async { seen.Enqueue x }) + |> run + + // The default options run one action at a time in the order of the elements + CollectionAssert.AreEqual ([| 1; 2; 3 |], seen.ToArray (), "iterAsync must apply the action to every element in order") + } + + [] + member _.``iterAsync completes without invoking the action for an empty source`` () : Task = task { + let seen = ConcurrentQueue() + + do! + empty () + |> Observable.iterAsync ProcessingOptions.Default (fun x -> async { seen.Enqueue x }) + |> run + + Assert.IsEmpty (seen, "iterAsync must not invoke the action when the source has no element") + } + + [] + member _.``iterAsync raises a terminal failure of the source as the same exception`` () : Task = task { + // A failure of the source makes SelectAwait cancel its running actions and publish the failure at once, + // which faults the ForEachAsync of iterAsync with that exception + let iterTask = + Sources.failingAfter [| 1; 2 |] boom + |> Observable.iterAsync ProcessingOptions.Default (fun _ -> async { return () }) + |> run + + let! error = + Assert.ThrowsAsync((fun () -> iterTask :> Task), "iterAsync must raise the failure of the source") + Assert.AreSame (boom, error, "iterAsync must raise the exception of the source itself, not a wrapper") + } + + [] + member _.``iterAsync raises the exception of the action as the same instance and stops processing`` () : Task = task { + use subject = new Subject () + let invoked = ConcurrentQueue() + + let action x = async { + invoked.Enqueue x + if x = 2 then + raise boom + } + + let iterTask = + subject + |> Observable.iterAsync ProcessingOptions.Default action + |> run + subject.OnNext 1 + subject.OnNext 2 + subject.OnNext 3 + + // SelectAwait reports the exception of the action through OnErrorResume, which faults the ForEachAsync of iterAsync + let! error = + Assert.ThrowsAsync((fun () -> iterTask :> Task), "iterAsync must raise the exception of the action") + Assert.AreSame (boom, error, "iterAsync must raise the exception of the action itself, not a wrapper") + // The fault disposes the mapped sequence, whose sequential worker checks its cancelled token before the next element + CollectionAssert.AreEqual ([| 1; 2 |], invoked.ToArray (), "The action must not be invoked after it failed") + } + + [] + member _.``iterAsync stops invoking the action after it failed even when a synchronous source emits during the subscription`` () : Task = task { + let invoked = ConcurrentQueue() + + let action x = async { + invoked.Enqueue x + if x = 2 then + raise boom + } + + // The source emits every element while iterAsync subscribes, before R3 could dispose the mapped sequence, + // so it is the library that skips the elements after the failure + let iterTask = + Sources.values [| 1; 2; 3; 4; 5 |] + |> Observable.iterAsync ProcessingOptions.Default action + |> run + + let! error = + Assert.ThrowsAsync((fun () -> iterTask :> Task), "iterAsync must raise the exception of the action") + Assert.AreSame (boom, error, "iterAsync must raise the exception of the action itself, not a wrapper") + CollectionAssert.AreEqual ([| 1; 2 |], invoked.ToArray (), "The action must not be invoked after it failed") + } + + [] + member _.``iterAsync raises a failure of the action that happens after the source completed`` () : Task = task { + let gate = TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously) + + let action _ = async { + do! Async.AwaitTask gate.Task + raise boom + } + + let iterTask = + Sources.values [| 1 |] + |> Observable.iterAsync ProcessingOptions.Default action + |> run + + // The source has completed while the action still runs + Assert.IsFalse (iterTask.IsCompleted, "iterAsync must wait for the running action") + // R3 drops an error that the mapped sequence reports after its source completed and would complete the iteration + // successfully; the library raises the failure it recorded when the action failed + gate.SetResult () + + let! error = + Assert.ThrowsAsync((fun () -> iterTask :> Task), "iterAsync must raise the late failure of the action") + Assert.AreSame (boom, error, "iterAsync must raise the exception of the action itself") + } + + [] + member _.``iterAsync completes successfully when an action superseded by AwaitSwitch raises a cancellation`` () : Task = task { + use subject = new Subject () + // Without RunContinuationsAsynchronously, releasing the first gate resumes its action inline, so the action has raised + // its cancellation by the time SetResult returns. Its token is already cancelled, so FSharp.Core cancels the superseded + // computation without running the try/with handler of iterAsync, and R3 drops the cancelled invocation + let firstGate = TaskCompletionSource () + let lastGate = TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously) + let options = { ProcessingOptions.Default with AwaitOperationConfiguration = AwaitSwitch } + + let action x = async { + let! cancellationToken = Async.CancellationToken + if x = 1 then + do! Async.AwaitTask firstGate.Task + // A well-behaved action that notices its cancellation after a wait that does not observe the token + cancellationToken.ThrowIfCancellationRequested () + else + do! Async.AwaitTask lastGate.Task + } + + let iterTask = subject |> Observable.iterAsync options action |> run + subject.OnNext 1 + // AwaitSwitch cancels the token of the running first action when the next element arrives + subject.OnNext 2 + firstGate.SetResult () + subject.OnCompleted (Result.Success) + lastGate.SetResult () + + // R3 swallows the cancellation of a superseded action, and the library must not count it as a failure either + do! iterTask + Assert.IsTrue (iterTask.IsCompletedSuccessfully, "iterAsync must complete successfully when only a superseded action was cancelled") + } + + [] + member _.``iterAsync stops at an OperationCanceledException that the action raises by itself instead of hanging`` () : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + let invoked = ConcurrentQueue() + // Like the TaskCanceledException of an HttpClient timeout: raised while the computation of the action is not cancelled + let timeout : exn = TaskCanceledException "timeout" + + let action x = async { + invoked.Enqueue x + if x = 2 then + raise timeout + } + + let iterTask = + subject + |> probe.Watch + |> Observable.iterAsync ProcessingOptions.Default action + |> run + subject.OnNext 1 + subject.OnNext 2 + + // R3 swallows an OperationCanceledException of the selector, which stops its sequential worker for good without completing; + // the library records it as the failure of the iteration instead + let! error = + Assert.ThrowsAsync((fun () -> iterTask :> Task), "iterAsync must raise the exception of the action") + Assert.AreSame (timeout, error, "iterAsync must raise the exception of the action itself") + subject.OnNext 3 + CollectionAssert.AreEqual ([| 1; 2 |], invoked.ToArray (), "The action must not be invoked after it failed") + // R3 completes the task of the iteration before it disposes the subscription, so the disposal is awaited, not assumed + do! probe.WaitForDisposedAsync (testContext.CancellationToken, 1) + Assert.AreEqual (0, probe.Active, "The failure of the action must unsubscribe from the source") + } + + [] + member _.``iterAsync started with an already cancelled token is cancelled without invoking the action`` () : Task = task { + let invoked = ConcurrentQueue() + let probe = SubscriptionProbe () + use cancellation = new CancellationTokenSource () + cancellation.Cancel () + + // FSharp.Core stops a cancelled computation at its first bind, the let! that binds the token of the iteration, + // before iterAsync subscribes. The guard of iterAsync would skip every action of a subscribed iteration anyway, + // so only the probe shows that the source is left alone + let iterTask = + Sources.values [| 1; 2; 3 |] + |> probe.Watch + |> Observable.iterAsync ProcessingOptions.Default (fun x -> async { invoked.Enqueue x }) + |> AsyncTest.start cancellation.Token + + let! _ = + Assert.ThrowsAsync((fun () -> iterTask :> Task), "A cancelled iterAsync must raise a cancellation") + Assert.IsTrue (iterTask.IsCanceled, "The task of the cancelled iterAsync must be cancelled, not faulted") + Assert.IsEmpty (invoked, "The action must not be invoked with an already cancelled token") + Assert.AreEqual (0, probe.Subscribed, "A computation started with an already cancelled token must not subscribe to the source") + } + + [] + member _.``cancelling iterAsync cancels the token of the running action and unsubscribes from the source`` () : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + // The gate of the action is never released: the cancellation has to stop the computation on its own + let action = GatedSelector ignore + use cancellation = CancellationTokenSource.CreateLinkedTokenSource testContext.CancellationToken + + // Parallel starts an action inside OnNext, so an element that still reached the iteration after the cancellation + // would be in Started at once; the sequential worker would stay parked on the unreleased action 1 instead + let iterTask = + subject + |> probe.Watch + |> Observable.iterAsync ProcessingOptions.Parallel action.InvokeAsync + |> AsyncTest.start cancellation.Token + + subject.OnNext 1 + do! action.WaitForStartedAsync (testContext.CancellationToken, 1) + Assert.IsFalse (action.Tokens[0].IsCancellationRequested, "The running action must not be cancelled before the computation is") + + // R3 disposes the subscription of ForEachAsync inside Cancel, and disposing the mapped sequence cancels the token + // that SelectAwait passed to the running action as the token of its computation + cancellation.Cancel () + Assert.AreEqual (1, probe.Disposed, "Cancelling must unsubscribe from the source before Cancel returns") + Assert.IsTrue (action.Tokens[0].IsCancellationRequested, "Cancelling must cancel the token of the running action") + subject.OnNext 2 + CollectionAssert.AreEqual ([| 1 |], action.Started, "No action may start after the cancellation") + + let! _ = + Assert.ThrowsAsync((fun () -> iterTask :> Task), "Awaiting a cancelled iterAsync must raise a cancellation") + Assert.IsTrue (iterTask.IsCanceled, "The task of a cancelled iterAsync must be cancelled, not faulted") + } + + [] + member _.``iterAsync throws ArgumentOutOfRangeException when called with a concurrency limit of zero`` () = + let options = { + ProcessingOptions.Default with + AwaitOperationConfiguration = AwaitOperationConfiguration.AwaitParallel 0 + } + + // R3 would only reject the limit when the mapped sequence is subscribed; the library validates it when called + assertArgumentRejected + (Observable.iterAsync options (fun _ -> async { return () })) + "options" + 0 + "iterAsync must validate the options when it is called, before the computation starts" + + // mapAsync + + [] + member _.``mapAsync with the default options projects every element in order`` () : Task = task { + use recorder = + Sources.values [| 1; 2; 3 |] + |> Observable.mapAsync ProcessingOptions.Default (fun x -> async { return x * 10 }) + |> Recorder.Attach + + // The sequential worker of SelectAwait completes the mapped sequence once it has drained its queue, + // which is not guaranteed to happen during the subscription + let! completion = recorder.WaitForCompletionAsync testContext.CancellationToken + + Assert.IsTrue (completion.IsSuccess, "The mapped sequence must complete successfully with the source") + CollectionAssert.AreEqual ([| 10; 20; 30 |], recorder.Values, "mapAsync must emit the projections in the order of the elements") + Assert.IsEmpty (recorder.Errors, "No error may be reported") + } + + // toLookup + + [] + member _.``toLookup groups the elements by key`` () : Task = task { + let! lookup = run (Observable.toLookup (Fruits.source (), Fruits.initial)) + + // The default comparer of string keys is case-sensitive, so every initial is a key of its own + Assert.HasCount (5, lookup, "Every distinct initial must be a key") + CollectionAssert.AreEqual ([| "apple" |], lookup["a"] |> Seq.toArray, "The group of a key must hold its elements") + CollectionAssert.AreEqual ([| "Avocado" |], lookup["A"] |> Seq.toArray, "Keys that differ in case must be separate groups") + } + + [] + member _.``toLookup with a positional keyComparer groups the keys with the comparer`` () : Task = task { + // Regression: a positional third argument used to bind to an unused cancellationToken parameter, which dropped the comparer + let! positional = + run (Observable.toLookup (Fruits.source (), Fruits.initial, StringComparer.OrdinalIgnoreCase)) + let! named = + run (Observable.toLookup (Fruits.source (), Fruits.initial, keyComparer = StringComparer.OrdinalIgnoreCase)) + + Assert.HasCount (3, positional, "A positional keyComparer must merge the initials that differ only in case") + CollectionAssert.AreEqual ([| "apple"; "Avocado" |], positional["a"] |> Seq.toArray, "A merged group must hold its elements in arrival order") + CollectionAssert.AreEqual ( + [| "banana"; "Blueberry" |], + positional["B"] |> Seq.toArray, + "The comparer must also find a key that differs in case" + ) + Assert.HasCount (3, named, "A named keyComparer must group the keys the same way") + CollectionAssert.AreEqual ([| "apple"; "Avocado" |], named["A"] |> Seq.toArray, "A named keyComparer must merge the same groups") + } + + [] + member _.``toLookup with a positional elementSelector projects the grouped elements`` () : Task = task { + // Regression: a positional elementSelector used to bind to an unused cancellationToken parameter, so the result was a + // lookup of the unprojected strings; the annotated element types are part of the assertion + let! (positional : ILookup) = + run (Observable.toLookup (Fruits.source (), Fruits.initial, Fruits.nameLength)) + let! (named : ILookup) = + run (Observable.toLookup (Fruits.source (), Fruits.initial, elementSelector = Fruits.nameLength)) + + Assert.HasCount (5, positional, "Without a comparer every distinct initial must be a key") + CollectionAssert.AreEqual ([| 6 |], positional["b"] |> Seq.toArray, "A positional elementSelector must project the grouped elements") + CollectionAssert.AreEqual ([| 9 |], positional["B"] |> Seq.toArray, "Every group must hold the projected elements") + Assert.HasCount (5, named, "A named elementSelector must group the keys the same way") + CollectionAssert.AreEqual ([| 6 |], named["b"] |> Seq.toArray, "A named elementSelector must project the grouped elements") + } + + [] + member _.``toLookup with an elementSelector and a keyComparer projects the elements and groups the keys with the comparer`` () : Task = task { + let! positional = + run (Observable.toLookup (Fruits.source (), Fruits.initial, Fruits.nameLength, StringComparer.OrdinalIgnoreCase)) + let! named = + run ( + Observable.toLookup ( + Fruits.source (), + Fruits.initial, + elementSelector = Fruits.nameLength, + keyComparer = StringComparer.OrdinalIgnoreCase + ) + ) + + Assert.HasCount (3, positional, "The keyComparer must merge the initials that differ only in case") + CollectionAssert.AreEqual ([| 6; 9 |], positional["B"] |> Seq.toArray, "The merged group must hold the projected elements in arrival order") + Assert.HasCount (3, named, "Named arguments must group the keys the same way") + CollectionAssert.AreEqual ([| 5; 7 |], named["a"] |> Seq.toArray, "Named arguments must project and merge the same groups") + } + + [] + member _.``toLookup returns an empty lookup for an empty source`` () : Task = task { + let! lookup = run (Observable.toLookup (empty (), fun x -> x % 2)) + + Assert.IsEmpty (lookup, "A source without elements must give a lookup without keys") + Assert.IsFalse (lookup.Contains 0, "The empty lookup must not contain any key") + // The lookup of R3 returns an empty sequence for a missing key + Assert.IsEmpty (lookup[0], "A missing key must map to an empty group") + } + + // Every terminal function + + [] + member _.``a terminal function raises a terminal failure of the source as the same exception`` (functionName : string) : Task = task { + let source = Sources.failingAfter (undecidedElements functionName) boom + + // R3 faults every terminal task with the exception of OnCompleted(Failure) itself, and the Async flavour raises the + // single inner exception of the faulted task rather than the AggregateException that Async.AwaitTask raises + let! error = + Assert.ThrowsAsync( + (fun () -> run (Terminals.runAsync functionName source) :> Task), + $"%s{functionName} must raise the failure of the source" + ) + + Assert.AreSame (boom, error, $"%s{functionName} must raise the exception of the source itself, not a wrapper") + } + + [] + member _.``a terminal function raises an OnErrorResume error as the same exception and unsubscribes`` (functionName : string) : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + let terminalTask = + subject + |> probe.Watch + |> Terminals.runAsync functionName + |> run + + for element in undecidedElements functionName do + subject.OnNext element + + Assert.IsFalse (terminalTask.IsCompleted, $"%s{functionName} must wait while the source neither fails nor completes") + // R3 terminal operators treat OnErrorResume as fatal: they fault with that exception and dispose their subscription + subject.OnErrorResume boom + Assert.AreEqual (1, probe.Disposed, $"%s{functionName} must unsubscribe from the source when the source reports an error") + + let! error = + Assert.ThrowsAsync( + (fun () -> terminalTask :> Task), + $"%s{functionName} must raise the error that the source reported" + ) + + Assert.AreSame (boom, error, $"%s{functionName} must raise the reported exception itself, not a wrapper") + } + + [] + member _.``cancelling the Async token cancels a terminal function and unsubscribes from the source`` (functionName : string) : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + use cancellation = CancellationTokenSource.CreateLinkedTokenSource testContext.CancellationToken + let terminalTask = + subject + |> probe.Watch + |> Terminals.runAsync functionName + |> AsyncTest.start cancellation.Token + + for element in undecidedElements functionName do + subject.OnNext element + + Assert.AreEqual (1, probe.Active, $"%s{functionName} must stay subscribed while its result is undecided") + // R3 registers on the token of the computation and disposes its subscription inside Cancel + cancellation.Cancel () + Assert.AreEqual (1, probe.Disposed, $"%s{functionName} must unsubscribe from the source as soon as the token is cancelled") + + let! _ = + Assert.ThrowsAsync( + (fun () -> terminalTask :> Task), + $"Awaiting a cancelled %s{functionName} must raise a cancellation" + ) + + // The cancelled R3 task cancels the computation instead of failing it with a TaskCanceledException + Assert.IsTrue (terminalTask.IsCanceled, $"The task of a cancelled %s{functionName} must be cancelled, not faulted") + } + + [] + member _.``a terminal function started with a cancelled token is cancelled without keeping a subscription`` (functionName : string) : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + use cancellation = CancellationTokenSource.CreateLinkedTokenSource testContext.CancellationToken + cancellation.Cancel () + + let terminalTask = + subject + |> probe.Watch + |> Terminals.runAsync functionName + |> AsyncTest.start cancellation.Token + + // FSharp.Core stops a cancelled computation at its first bind, before the R3 operator subscribes, and R3 would + // dispose a subscription made with a cancelled token at once, so no subscription survives either way + Assert.AreEqual (0, probe.Active, $"%s{functionName} must not keep a subscription when started with a cancelled token") + + let! _ = + Assert.ThrowsAsync( + (fun () -> terminalTask :> Task), + $"Awaiting %s{functionName} started with a cancelled token must raise a cancellation" + ) + + Assert.IsTrue (terminalTask.IsCanceled, $"The task of %s{functionName} started with a cancelled token must be cancelled") + } + + [] + member _.``a terminal function resumes on the synchronization context that was current when it started to wait`` (functionName : string) : Task = + task { + use subject = new Subject () + let context = RecordingSynchronizationContext () + let resumed = TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously) + + // The computation subscribes and starts to wait while the context is current, like a UI handler + context.Run (fun () -> + Async.StartImmediate ( + async { + do! Terminals.runAsync functionName subject + resumed.SetResult () + }, + testContext.CancellationToken + ) + ) + + // The source completes on the test thread, which has no context: like Async.AwaitTask, the terminal function must post + // the rest of the computation to the captured context instead of running it on the thread that completed the source + subject.OnNext 1 + subject.OnCompleted (Result.Success) + do! resumed.Task.WaitAsync testContext.CancellationToken + + Assert.IsTrue (context.Posts >= 1, $"%s{functionName} must resume through the synchronization context it started to wait on") + } diff --git a/tests/FSharp.Control.R3.Tests/BuilderTests.fs b/tests/FSharp.Control.R3.Tests/BuilderTests.fs index 53ec263..d070165 100644 --- a/tests/FSharp.Control.R3.Tests/BuilderTests.fs +++ b/tests/FSharp.Control.R3.Tests/BuilderTests.fs @@ -1,53 +1,962 @@ namespace FSharp.Control.R3.Tests open System +open System.Threading open System.Threading.Tasks -open FSharp.Control.R3.Async -open FSharp.Control.R3.Observable.Builders open Microsoft.VisualStudio.TestTools.UnitTesting -open Swensen.Unquote +open R3 +open FSharp.Control.R3.Observable.Builders +open FSharp.Control.R3.Task +open FSharp.Control.R3.Tests.TestHelpers + +// Public, because F# solves the (+) constraint of the inline sumBy only with public members +/// Types that the builder tests aggregate. +[] +module BuilderTestTypes = + + /// + /// An amount of money: a reference type whose addition reads both operands, + /// so adding an amount to a seed throws a . + /// + type Money = { + Cents : int64 + } with + + /// Adds two amounts. + static member (+) (left : Money, right : Money) = { Cents = left.Cents + right.Cents } +/// +/// Integration tests of the query expressions over R3. +/// +/// and +/// emit synchronously, so a query over +/// synchronous sources keeps the order of the elements and completes during the subscription: the tests assert right after +/// building or collecting such a query, without waiting. +/// +/// [] -type BuilderTests () = +type BuilderTests (testContext : TestContext) = + + // Collects a query over synchronous sources. Such a query completes during the subscription, so the task is already + // completed here; a faulted task rethrows its original exception when the test awaits it. + let collect (query : Observable<'T>) = + let valuesTask = query |> Observable.toArray testContext.CancellationToken + Assert.IsTrue (valuesTask.IsCompleted, "A query over synchronous sources must complete during the subscription") + valuesTask + + [] + member _.``rxquery for and select project every element in order`` () : Task = task { + let query = rxquery { + for x in Sources.values [| 1..6 |] do + select (x * 10) + } + + let! values = collect query + CollectionAssert.AreEqual ([| 10; 20; 30; 40; 50; 60 |], values, "select must project every element in the order of the source") + } + + [] + member _.``rxquery over a synchronous source completes synchronously`` () : Task = task { + let countTask = rxquery { + for x in Sources.values [| 1..6 |] do + count + } + + // Yield is Observable.Return, which emits during the subscription; when it was scheduled on TimeProvider.System, + // every element hopped to the thread pool and the task completed only later + Assert.IsTrue (countTask.IsCompletedSuccessfully, "count over a synchronous source must complete before the query returns") + let! elementCount = countTask + Assert.AreEqual (6, elementCount, "count must count every element of the source") + } + + [] + member _.``rxquery where keeps only the elements that satisfy the predicate`` () : Task = task { + let query = rxquery { + for x in Sources.values [| 1..6 |] do + where (x % 2 = 0) + select x + } + + let! values = collect query + CollectionAssert.AreEqual ([| 2; 4; 6 |], values, "where must keep the even elements in order") + } + + [] + member _.``rxquery if-then yield skips the other elements through Zero`` () : Task = task { + let query = rxquery { + for x in Sources.values [| 1..6 |] do + if x % 2 = 0 then + yield x + } + + let! values = collect query + CollectionAssert.AreEqual ([| 2; 4; 6 |], values, "An if-then without else must yield the even elements and skip the odd ones") + } + + [] + member _.``rxquery nested for produces the cartesian product in order`` () : Task = task { + let query = rxquery { + for x in Sources.values [| 1; 2 |] do + for y in Sources.values [| "a"; "b" |] do + yield struct (x, y) + } + + // Every inner query emits and completes during its subscription, so SelectMany emits it whole before the next outer element + let! pairs = collect query + CollectionAssert.AreEqual ( + [| struct (1, "a"); struct (1, "b"); struct (2, "a"); struct (2, "b") |], + pairs, + "Nested for must pair every outer element with every inner element, outer element first" + ) + } + + [] + member _.``rxquery takeWhile completes at the first element that fails the predicate`` () : Task = task { + let query = rxquery { + for x in Sources.values [| 1; 2; 3; 4; 1 |] do + takeWhile (x < 4) + } + + // The trailing 1 satisfies the predicate again, but TakeWhile has completed at 4 + let! values = collect query + CollectionAssert.AreEqual ([| 1; 2; 3 |], values, "takeWhile must stop at the first element that fails the predicate") + } + + [] + member _.``rxquery take emits the first elements then completes and unsubscribes from a hot source`` () : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + + let valuesTask = + rxquery { + for x in probe.Watch subject do + take 2 + } + |> Observable.toArray testContext.CancellationToken + + subject.OnNext 1 + Assert.IsFalse (valuesTask.IsCompleted, "take must wait for its second element") + subject.OnNext 2 + // Take completes at its last element and the completed terminal disposes the query down to the subject; + // the subject never completes by itself, so the probe proves the unsubscription + Assert.IsTrue (valuesTask.IsCompletedSuccessfully, "take must complete at its second element") + Assert.AreEqual (1, probe.Disposed, "Completing take must dispose the subscription to the subject") + let! values = valuesTask.WaitAsync testContext.CancellationToken + CollectionAssert.AreEqual ([| 1; 2 |], values, "take must emit the first two elements in order") + } + + [] + member _.``rxquery skipWhile bypasses only the leading elements that satisfy the predicate`` () : Task = task { + let query = rxquery { + for x in Sources.values [| 1; 2; 3; 1 |] do + skipWhile (x < 3) + } + + let! values = collect query + CollectionAssert.AreEqual ([| 3; 1 |], values, "skipWhile must emit every element from the first one that fails the predicate") + } + + [] + member _.``rxquery skip bypasses the first elements`` () : Task = task { + let query = rxquery { + for x in Sources.values [| 1..6 |] do + skip 4 + } + + let! values = collect query + CollectionAssert.AreEqual ([| 5; 6 |], values, "skip must bypass the first four elements") + } [] - member _.``Test builder rxquery`` () = + member _.``rxquery distinct removes repeated elements and keeps the first occurrences in order`` () : Task = task { + let query = rxquery { + for x in Sources.values [| 1; 2; 1; 3; 2 |] do + distinct + } + + let! values = collect query + CollectionAssert.AreEqual ([| 1; 2; 3 |], values, "distinct must emit every element once, at its first occurrence") + } + + [] + member _.``rxquery count counts the elements that pass the query`` () : Task = task { + let matchingTask = rxquery { + for x in Sources.values [| 1..6 |] do + where (x > 2) + count + } + + let emptyTask = rxquery { + for x in Sources.values Array.empty do + count + } + + let! matching = matchingTask.WaitAsync testContext.CancellationToken + let! emptyCount = emptyTask.WaitAsync testContext.CancellationToken + Assert.AreEqual (4, matching, "count must count the elements that pass where") + Assert.AreEqual (0, emptyCount, "count over an empty source must be 0") + } + + [] + member _.``rxquery all returns false at the first failing element and unsubscribes from a hot source`` () : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + + let allTask = rxquery { + for x in probe.Watch subject do + all (x < 3) + } + + subject.OnNext 1 + subject.OnNext 2 + Assert.IsFalse (allTask.IsCompleted, "all must wait while every element satisfies the predicate") + subject.OnNext 3 + // R3 AllAsync decides at the first failing element and disposes its subscription, which unsubscribes from the subject; + // the subject never completes by itself, so the probe proves the short-circuit + Assert.IsTrue (allTask.IsCompletedSuccessfully, "all must complete at the first failing element, before the source completes") + Assert.AreEqual (1, probe.Disposed, "all must dispose the subscription to the source at the first failing element") + let! result = allTask.WaitAsync testContext.CancellationToken + Assert.IsFalse (result, "all must be false when an element fails the predicate") + } + + [] + member _.``rxquery all returns true when every element satisfies the predicate or the source is empty`` () : Task = task { + let everyTask = rxquery { + for x in Sources.values [| 1..6 |] do + all (x > 0) + } + + let emptyTask = rxquery { + for x in Sources.values Array.empty do + all (x > 0) + } + + let! every = everyTask.WaitAsync testContext.CancellationToken + let! overEmpty = emptyTask.WaitAsync testContext.CancellationToken + Assert.IsTrue (every, "all must be true when every element satisfies the predicate") + // R3 AllAsync completes with true when the source completes without a failing element + Assert.IsTrue (overEmpty, "all over an empty source must be true") + } + + [] + member _.``rxquery contains finds an element and returns false otherwise`` () : Task = task { + let presentTask = rxquery { + for x in Sources.values [| 1..6 |] do + contains 3 + } + + let missingTask = rxquery { + for x in Sources.values [| 1..6 |] do + contains 42 + } - let mutable hasvisited = false - use r3Bus = new R3.Subject () + let emptyTask = rxquery { + for x in Sources.values Array.empty do + contains 3 + } + + let! present = presentTask.WaitAsync testContext.CancellationToken + let! missing = missingTask.WaitAsync testContext.CancellationToken + let! inEmpty = emptyTask.WaitAsync testContext.CancellationToken + Assert.IsTrue (present, "contains must find an element of the source") + Assert.IsFalse (missing, "contains must be false for an element that is not in the source") + Assert.IsFalse (inEmpty, "contains over an empty source must be false") + } + + [] + member _.``rxquery exactlyOne returns the only element and fails for an empty source`` () : Task = task { + let singleTask = rxquery { + for x in Sources.values [| 7 |] do + exactlyOne + } + + let emptyTask = rxquery { + for x in Sources.values Array.empty do + exactlyOne + } + + // R3 SingleAsync fails with an InvalidOperationException when the source completes without an element + let! _ = + Assert.ThrowsExactlyAsync( + (fun () -> emptyTask.WaitAsync testContext.CancellationToken :> Task), + "exactlyOne over an empty source must fail" + ) + let! single = singleTask.WaitAsync testContext.CancellationToken + Assert.AreEqual (7, single, "exactlyOne must return the only element") + } + + [] + member _.``rxquery exactlyOne fails at the second element of a hot source without waiting for completion`` () : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + + let singleTask = rxquery { + for x in probe.Watch subject do + exactlyOne + } + + subject.OnNext 7 + Assert.IsFalse (singleTask.IsCompleted, "exactlyOne must wait for the source to complete after the first element") + subject.OnNext 8 + // R3 SingleAsync fails as soon as a second element arrives and disposes its subscription; + // the subject never completes by itself, so the probe proves the unsubscription + Assert.IsTrue (singleTask.IsFaulted, "exactlyOne must fail at the second element, before the source completes") + Assert.AreEqual (1, probe.Disposed, "exactlyOne must dispose the subscription to the source at the second element") + let! _ = + Assert.ThrowsExactlyAsync( + (fun () -> singleTask.WaitAsync testContext.CancellationToken :> Task), + "exactlyOne over two elements must fail" + ) + () + } + + [] + member _.``rxquery exactlyOneOrDefault returns the default only for an empty source`` () : Task = task { + let singleTask = rxquery { + for x in Sources.values [| 7 |] do + exactlyOneOrDefault + } + + let emptyTask = rxquery { + for x in Sources.values Array.empty do + exactlyOneOrDefault + } + + let manyTask = rxquery { + for x in Sources.values [| 1; 2; 3 |] do + exactlyOneOrDefault + } + + // R3 SingleOrDefaultAsync applies the default value to an empty source only; a second element still fails + let! _ = + Assert.ThrowsExactlyAsync( + (fun () -> manyTask.WaitAsync testContext.CancellationToken :> Task), + "exactlyOneOrDefault over several elements must fail" + ) + + let! single = singleTask.WaitAsync testContext.CancellationToken + let! orDefault = emptyTask.WaitAsync testContext.CancellationToken + Assert.AreEqual (7, single, "exactlyOneOrDefault must return the only element") + Assert.AreEqual (0, orDefault, "exactlyOneOrDefault over an empty source must return the default value") + } + + [] + member _.``rxquery find returns the first matching element and fails when none matches`` () : Task = task { + let foundTask = rxquery { + for x in Sources.values [| 1..6 |] do + find (x > 2) + } + + let missingTask = rxquery { + for x in Sources.values [| 1..6 |] do + find (x > 9) + } + + // R3 FirstAsync with a predicate fails with an InvalidOperationException when the source completes without a match + let! _ = + Assert.ThrowsExactlyAsync( + (fun () -> missingTask.WaitAsync testContext.CancellationToken :> Task), + "find without a matching element must fail" + ) + let! found = foundTask.WaitAsync testContext.CancellationToken + Assert.AreEqual (3, found, "find must return the first element that satisfies the predicate") + } + + [] + member _.``rxquery head returns the first element and fails for an empty source`` () : Task = task { + let headTask = rxquery { + for x in Sources.values [| 1..6 |] do + head + } + + let emptyTask = rxquery { + for x in Sources.values Array.empty do + head + } + + // R3 FirstAsync fails with an InvalidOperationException when the source completes without an element + let! _ = + Assert.ThrowsExactlyAsync( + (fun () -> emptyTask.WaitAsync testContext.CancellationToken :> Task), + "head over an empty source must fail" + ) + let! first = headTask.WaitAsync testContext.CancellationToken + Assert.AreEqual (1, first, "head must return the first element") + } + + [] + member _.``rxquery head completes at the first element of a hot source and unsubscribes`` () : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + + let headTask = rxquery { + for x in probe.Watch subject do + head + } + + Assert.IsFalse (headTask.IsCompleted, "head must wait for the first element") + subject.OnNext 5 + // R3 FirstAsync completes at the first element and disposes its subscription; + // the subject never completes by itself, so the probe proves the unsubscription + Assert.IsTrue (headTask.IsCompletedSuccessfully, "head must complete at the first element, before the source completes") + Assert.AreEqual (1, probe.Disposed, "head must dispose the subscription to the source at the first element") + let! first = headTask.WaitAsync testContext.CancellationToken + Assert.AreEqual (5, first, "head must return the first element pushed after the subscription") + } + + [] + member _.``rxquery headOrDefault returns the first element or the default for an empty source`` () : Task = task { + let headTask = rxquery { + for x in Sources.values [| 1..6 |] do + headOrDefault + } + + let emptyTask = rxquery { + for x in Sources.values Array.empty do + headOrDefault + } + + let! first = headTask.WaitAsync testContext.CancellationToken + let! orDefault = emptyTask.WaitAsync testContext.CancellationToken + Assert.AreEqual (1, first, "headOrDefault must return the first element") + Assert.AreEqual (0, orDefault, "headOrDefault over an empty source must return the default value") + } + + [] + member _.``rxquery last returns the last element and fails for an empty source`` () : Task = task { + let lastTask = rxquery { + for x in Sources.values [| 1..6 |] do + last + } + + let emptyTask = rxquery { + for x in Sources.values Array.empty do + last + } + + // R3 LastAsync fails with an InvalidOperationException when the source completes without an element + let! _ = + Assert.ThrowsExactlyAsync( + (fun () -> emptyTask.WaitAsync testContext.CancellationToken :> Task), + "last over an empty source must fail" + ) + let! final = lastTask.WaitAsync testContext.CancellationToken + Assert.AreEqual (6, final, "last must return the last element") + } + + [] + member _.``rxquery lastOrDefault returns the last element or the default for an empty source`` () : Task = task { + let lastTask = rxquery { + for x in Sources.values [| 1..6 |] do + lastOrDefault + } + + let emptyTask = rxquery { + for x in Sources.values Array.empty do + lastOrDefault + } + + let! final = lastTask.WaitAsync testContext.CancellationToken + let! orDefault = emptyTask.WaitAsync testContext.CancellationToken + Assert.AreEqual (6, final, "lastOrDefault must return the last element") + Assert.AreEqual (0, orDefault, "lastOrDefault over an empty source must return the default value") + } + + [] + member _.``rxquery maxBy and minBy return the first element with the extreme projected value`` () : Task = task { + // "ccc" and "eee" share the largest length, "a" and "f" the smallest one + let words = Sources.values [| "bb"; "a"; "ccc"; "dd"; "eee"; "f" |] - let interesting = rxquery { - for i in r3Bus do + let longestTask = rxquery { + for word in words do + maxBy word.Length + } + + let shortestTask = rxquery { + for word in words do + minBy word.Length + } + + let! longest = longestTask.WaitAsync testContext.CancellationToken + let! shortest = shortestTask.WaitAsync testContext.CancellationToken + // R3 MaxByAsync and MinByAsync replace the current element only on a strictly larger or smaller key, so the first one wins ties + Assert.AreEqual ("ccc", longest, "maxBy must return the first of the longest words") + Assert.AreEqual ("a", shortest, "minBy must return the first of the shortest words") + } + + [] + member _.``rxquery maxBy and minBy fail for an empty source`` () : Task = task { + let words = Sources.values Array.empty + + let longestTask = rxquery { + for word in words do + maxBy word.Length + } + + let shortestTask = rxquery { + for word in words do + minBy word.Length + } + + // R3 MaxByAsync and MinByAsync fail with an InvalidOperationException when the source has no element + let! _ = + Assert.ThrowsExactlyAsync( + (fun () -> longestTask.WaitAsync testContext.CancellationToken :> Task), + "maxBy over an empty source must fail" + ) + let! _ = + Assert.ThrowsExactlyAsync( + (fun () -> shortestTask.WaitAsync testContext.CancellationToken :> Task), + "minBy over an empty source must fail" + ) + () + } + + [] + member _.``rxquery sumBy adds integer float and TimeSpan values`` () : Task = task { + let numbers = Sources.values [| 1; 2; 3; 4 |] + + let integersTask = rxquery { + for x in numbers do + sumBy x + } + + let halvesTask = rxquery { + for x in numbers do + sumBy (float x / 2.0) + } + + let durationsTask = rxquery { + for x in numbers do + sumBy (TimeSpan.FromSeconds (float x)) + } + + let! integers = integersTask.WaitAsync testContext.CancellationToken + let! halves = halvesTask.WaitAsync testContext.CancellationToken + let! durations = durationsTask.WaitAsync testContext.CancellationToken + Assert.AreEqual (10, integers, "sumBy must add the integers") + Assert.AreEqual (5.0, halves, "sumBy must add the floats") + // TimeSpan has an addition operator but no Zero member, which the first-element seed does not need + Assert.AreEqual (TimeSpan.FromSeconds 10.0, durations, "sumBy must add the TimeSpan values") + } + + [] + member _.``rxquery sumBy returns the default value for an empty source`` () : Task = task { + let integersTask = rxquery { + for x in Sources.values Array.empty do + sumBy x + } + + let floatsTask = rxquery { + for x in Sources.values Array.empty do + sumBy x + } + + let durationsTask = rxquery { + for x in Sources.values Array.empty do + sumBy x + } + + let! integers = integersTask.WaitAsync testContext.CancellationToken + let! floats = floatsTask.WaitAsync testContext.CancellationToken + let! durations = durationsTask.WaitAsync testContext.CancellationToken + Assert.AreEqual (0, integers, "sumBy of no integer must be 0") + Assert.AreEqual (0.0, floats, "sumBy of no float must be 0.0") + Assert.AreEqual (TimeSpan.Zero, durations, "sumBy of no TimeSpan must be TimeSpan.Zero") + } + + [] + member _.``rxquery sumBy adds a record type that defines the addition operator`` () : Task = task { + let amounts = Sources.values [| { Cents = 150L }; { Cents = 250L }; { Cents = 100L } |] + + let totalTask = rxquery { + for amount in amounts do + sumBy amount + } + + // Regression: the sum was seeded with Unchecked.defaultof, a null Money, so the first addition threw a + // NullReferenceException and faulted the task; the first element seeds the sum now + let! total = totalTask.WaitAsync testContext.CancellationToken + Assert.AreEqual ({ Cents = 500L }, total, "sumBy must add the amounts with their addition operator") + } + + [] + member _.``rxquery zip pairs elements by position and completes with the shorter source`` () : Task = task { + let shorterQuery = rxquery { + for x in Sources.values [| 1; 2; 3 |] do + zip letter in Sources.values [| "a"; "b"; "c"; "d" |] + select (struct (x, letter)) + } + + let shorterOther = rxquery { + for x in Sources.values [| 1..6 |] do + zip letter in Sources.values [| "a"; "b" |] + select (struct (x, letter)) + } + + // R3 Zip pairs the queued elements of both sources in arrival order and completes once a completed source has no + // queued element left, so the extra elements of the longer source are dropped and collect sees the completion + let! pairs = collect shorterQuery + let! otherPairs = collect shorterOther + CollectionAssert.AreEqual ( + [| struct (1, "a"); struct (2, "b"); struct (3, "c") |], + pairs, + "zip must pair by position and complete with the shorter query" + ) + + CollectionAssert.AreEqual ( + [| struct (1, "a"); struct (2, "b") |], + otherPairs, + "zip must pair by position and complete with the shorter other source" + ) + } + + [] + member _.``rxquery zip subscribes to the query before the zipped source`` () = + let subscriptions = ResizeArray() + // Every source is synchronous, so the subscriptions are recorded on the test thread in the order they happen + let recordSubscription name (source : Observable<'T>) = source.Do (onSubscribe = (fun () -> subscriptions.Add name)) + + use recorder = + rxquery { + for x in Sources.values [| 1; 2 |] |> recordSubscription "query" do + zip letter in Sources.values [| "a"; "b" |] |> recordSubscription "zipped" + select (struct (x, letter)) + } + |> Recorder.Attach + + // R3 Zip subscribes its first source before its second one, so this order shows that the builder passes the query + // as the first source; the pairs cannot show it, because Zip queues the elements of each source and pairs them by + // position whichever source emits first + CollectionAssert.AreEqual ([| "query"; "zipped" |], subscriptions.ToArray (), "zip must subscribe to the query before the zipped source") + CollectionAssert.AreEqual ([| struct (1, "a"); struct (2, "b") |], recorder.Values, "zip must pair the elements by position") + + [] + member _.``rxquery zip fails with the failure of the query when both sources fail on subscription`` () = + let queryFailure : exn = InvalidOperationException "query" + let zippedFailure : exn = InvalidOperationException "zipped" + + use recorder = + rxquery { + for x in Sources.failingAfter Array.empty queryFailure do + zip letter in Sources.failingAfter Array.empty zippedFailure + select (struct (x, letter)) + } + |> Recorder.Attach + + // R3 Zip subscribes the query first and fails at the first failure of either source; the zipped source, subscribed + // after that, fails an observer that Zip has already disposed + assertFailedWith queryFailure recorder "zip must fail with the failure of the query, which it subscribes first" + + [] + member _.``rxquery iter runs the action for every element in order`` () : Task = task { + let seen = ResizeArray() + let untouched = ResizeArray() + + let iterTask = rxquery { + for x in Sources.values [| 1..6 |] do + iter (seen.Add x) + } + + let emptyTask = rxquery { + for x in Sources.values Array.empty do + iter (untouched.Add x) + } + + do! iterTask.WaitAsync testContext.CancellationToken + do! emptyTask.WaitAsync testContext.CancellationToken + CollectionAssert.AreEqual ([| 1..6 |], seen.ToArray (), "iter must run the action for every element in order") + Assert.IsEmpty (untouched, "iter over an empty source must never run the action") + } + + [] + member _.``rxquery iter faults with the exception of the action and stops processing`` () : Task = task { + let boom : exn = InvalidOperationException "boom" + let seen = ResizeArray() + + let iterTask = rxquery { + for x in Sources.values [| 1; 2; 3 |] do + iter (if x = 2 then raise boom else seen.Add x) + } + + // R3 reports the exception of the action through OnErrorResume, which faults ForEachAsync and disposes it, + // so the elements after it never reach the action + let! error = + Assert.ThrowsAsync( + (fun () -> iterTask.WaitAsync testContext.CancellationToken), + "iter must fault when the action throws" + ) + + Assert.AreSame (boom, error, "iter must fault with the exception of the action") + CollectionAssert.AreEqual ([| 1 |], seen.ToArray (), "iter must stop processing at the element whose action throws") + } + + [] + member _.``rxquery over a hot subject only sees elements pushed after subscription`` () : Task = task { + use subject = new Subject () + + let query = rxquery { + for i in subject do where (i % 2 = 0) select i + } - // Same as: - // if i % 2 = 0 then - // yield i + // Building the query does not subscribe, and a subject drops the elements nobody listens to, so 2 is lost + subject.OnNext 2 + let valuesTask = query |> Observable.toArray testContext.CancellationToken + subject.OnNext 3 + subject.OnNext 4 + subject.OnNext 5 + Assert.IsFalse (valuesTask.IsCompleted, "The query must stay open while the subject is open") + subject.OnCompleted (Result.Success) + Assert.IsTrue (valuesTask.IsCompletedSuccessfully, "Completing the subject must complete the query synchronously") + let! values = valuesTask.WaitAsync testContext.CancellationToken + CollectionAssert.AreEqual ([| 4 |], values, "Only the even element pushed after the subscription must arrive") + } + + [] + member _.``a failing source faults a task operator of rxquery with the same exception`` () : Task = task { + let boom : exn = InvalidOperationException "boom" + let countTask = rxquery { + for x in Sources.failingAfter [| 1; 2 |] boom do + count } - // No-one listens yet (vs R3.ReplaySubject - r3Bus.OnNext 2 + // SelectMany forwards the failure of the source at once, and an R3 task operator faults with the exception of a failure + Assert.IsTrue (countTask.IsFaulted, "count must fault as soon as the synchronous source fails") + let! error = + Assert.ThrowsAsync( + (fun () -> countTask.WaitAsync testContext.CancellationToken :> Task), + "Awaiting count over a failing source must throw" + ) + Assert.AreSame (boom, error, "count must fault with the exception of the source failure") + } + + [] + member _.``a failing source fails an observable rxquery after its earlier elements`` () = + let boom : exn = InvalidOperationException "boom" - use subscription = - R3.ObservableExtensions.SubscribeAwait ( - interesting, - fun i cancellationToken -> - task { - // Listen events + use recorder = + rxquery { + for x in Sources.failingAfter [| 1; 2 |] boom do + select (x * 10) + } + |> Recorder.Attach - hasvisited <- true + CollectionAssert.AreEqual ([| 10; 20 |], recorder.Values, "The elements emitted before the failure must arrive") + Assert.IsEmpty (recorder.Errors, "A terminal failure must not be reported through OnErrorResume") + assertFailedWith boom recorder "The query must fail with the exception of the source failure" - Assert.AreEqual(4, i) + [] + member _.``an error resumed by the source faults a task operator of rxquery`` () : Task = task { + let boom : exn = InvalidOperationException "boom" - return () - } - |> System.Threading.Tasks.ValueTask + let countTask = rxquery { + for x in Sources.resumingError boom do + count + } + + // An R3 task operator turns the first OnErrorResume into a fault and disposes its subscription + Assert.IsTrue (countTask.IsFaulted, "count must fault at the first resumed error") + let! error = + Assert.ThrowsAsync( + (fun () -> countTask.WaitAsync testContext.CancellationToken :> Task), + "Awaiting count after a resumed error must throw" ) + Assert.AreSame (boom, error, "count must fault with the resumed exception") + } + + [] + member _.``an error resumed by the source passes through an observable rxquery`` () = + let boom : exn = InvalidOperationException "boom" + + use recorder = + rxquery { + for x in Sources.resumingError boom do + select (x * 10) + } + |> Recorder.Attach + + // SelectMany and Select forward OnErrorResume without stopping, so the element after the error still arrives + CollectionAssert.AreEqual ([| 10; 20 |], recorder.Values, "The elements around the resumed error must arrive") + let error = Assert.ContainsSingle (recorder.Errors, "The resumed error must be forwarded once") + Assert.AreSame (boom, error, "The forwarded error must be the resumed exception") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "A resumed error must not terminate the query") + + [] + member _.``a throwing select faults toArray of the rxquery with the same exception`` () : Task = task { + let boom : exn = InvalidOperationException "boom" + + let query = rxquery { + for x in Sources.values [| 1; 2; 3 |] do + select (if x = 2 then raise boom else x * 10) + } + + let valuesTask = query |> Observable.toArray testContext.CancellationToken + // R3 reports the exception of the selector through OnErrorResume, which ToArrayAsync turns into a fault + Assert.IsTrue (valuesTask.IsFaulted, "toArray must fault at the element whose projection throws") + let! error = + Assert.ThrowsAsync( + (fun () -> valuesTask.WaitAsync testContext.CancellationToken :> Task), + "Awaiting toArray of the query must throw" + ) + Assert.AreSame (boom, error, "toArray must fault with the exception of the selector") + } + + [] + member _.``a throwing select is resumed by an observable rxquery and the later elements still arrive`` () = + let boom : exn = InvalidOperationException "boom" + + use recorder = + rxquery { + for x in Sources.values [| 1; 2; 3 |] do + select (if x = 2 then raise boom else x * 10) + } + |> Recorder.Attach + + // R3 Select turns the exception of the selector into OnErrorResume and keeps projecting + CollectionAssert.AreEqual ([| 10; 30 |], recorder.Values, "The elements whose projection succeeds must arrive") + let error = + Assert.ContainsSingle (recorder.Errors, "The exception of the selector must be resumed once") + Assert.AreSame (boom, error, "The resumed error must be the exception of the selector") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "An exception of the selector must not terminate the query") + + [] + member _.``rxqueryWith cancels head and unsubscribes from the source when its token is cancelled`` () : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + use cancellation = CancellationTokenSource.CreateLinkedTokenSource testContext.CancellationToken + + // The documented form: the application of rxqueryWith must be parenthesized in front of the query + let headTask = + (rxqueryWith cancellation.Token) + { for x in probe.Watch subject do + head } + + Assert.AreEqual (1, probe.Active, "head must subscribe to the source when the query is built") + Assert.IsFalse (headTask.IsCompleted, "head must wait for the first element") + // R3 registers on the token before subscribing; the callback runs inside Cancel, + // disposes the subscription and then cancels the task + cancellation.Cancel () + Assert.AreEqual (TaskStatus.Canceled, headTask.Status, "Cancel must cancel the task before it returns") + Assert.AreEqual (1, probe.Disposed, "Cancel must dispose the subscription to the subject, which never completes by itself") + + // Awaited directly rather than through WaitAsync: the task is already cancelled and must carry its own token + let! error = + Assert.ThrowsAsync((fun () -> headTask :> Task), "Awaiting the cancelled head must throw") + + Assert.AreEqual (cancellation.Token, error.CancellationToken, "The exception must carry the token of rxqueryWith") + } + + [] + member _.``rxqueryWith passes its token to every query operator that returns a task`` () = + use cancellation = new CancellationTokenSource () + cancellation.Cancel () + let cancelledQuery = rxqueryWith cancellation.Token + let source = Sources.values [| 3; 1; 2 |] + let processed = ResizeArray() + + // R3 registers on the token before it subscribes, and registering on a cancelled token runs the callback at once, + // so every operator that observes the token returns a cancelled task even over a synchronous source + let assertCancelled name (operatorTask : #Task) = + Assert.AreEqual (TaskStatus.Canceled, operatorTask.Status, $"%s{name} must be cancelled by the token of rxqueryWith") + + cancelledQuery { + for x in source do + count + } + |> assertCancelled "count" + + cancelledQuery { + for x in source do + all (x > 0) + } + |> assertCancelled "all" + + cancelledQuery { + for x in source do + contains 1 + } + |> assertCancelled "contains" + + cancelledQuery { + for x in source do + exactlyOne + } + |> assertCancelled "exactlyOne" + + cancelledQuery { + for x in source do + exactlyOneOrDefault + } + |> assertCancelled "exactlyOneOrDefault" + + cancelledQuery { + for x in source do + find (x > 0) + } + |> assertCancelled "find" + + cancelledQuery { + for x in source do + head + } + |> assertCancelled "head" + + cancelledQuery { + for x in source do + headOrDefault + } + |> assertCancelled "headOrDefault" + + cancelledQuery { + for x in source do + last + } + |> assertCancelled "last" + + cancelledQuery { + for x in source do + lastOrDefault + } + |> assertCancelled "lastOrDefault" + + cancelledQuery { + for x in source do + maxBy x + } + |> assertCancelled "maxBy" + + cancelledQuery { + for x in source do + minBy x + } + |> assertCancelled "minBy" + + cancelledQuery { + for x in source do + sumBy x + } + |> assertCancelled "sumBy" + + cancelledQuery { + for x in source do + iter (processed.Add x) + } + |> assertCancelled "iter" + + // The observer is disposed before the subscription, so the synchronous elements never reach the action + Assert.IsEmpty (processed, "No element may reach iter once its token is cancelled") + + [] + member _.``RxQueryBuilder exposes its token while rxquery uses an uncancellable token`` () = + use cancellation = new CancellationTokenSource () + let builder = RxQueryBuilder cancellation.Token + let withToken = rxqueryWith cancellation.Token + let withoutToken = RxQueryBuilder () - // Publish some events, "4" should be heard - [ 3..5 ] |> List.iter r3Bus.OnNext - // Note: Query will not be awaited, that's why delay. - System.Threading.Thread.Sleep 300 - Assert.AreEqual(true, hasvisited) + Assert.AreEqual (cancellation.Token, builder.CancellationToken, "RxQueryBuilder must expose the token it was created with") + Assert.AreEqual (cancellation.Token, withToken.CancellationToken, "rxqueryWith must create a builder with its token") + Assert.AreEqual (CancellationToken.None, rxquery.CancellationToken, "rxquery must not observe any token") + Assert.AreEqual (CancellationToken.None, withoutToken.CancellationToken, "The parameterless RxQueryBuilder must not observe any token") diff --git a/tests/FSharp.Control.R3.Tests/ChunkTests.fs b/tests/FSharp.Control.R3.Tests/ChunkTests.fs new file mode 100644 index 0000000..c7a0b78 --- /dev/null +++ b/tests/FSharp.Control.R3.Tests/ChunkTests.fs @@ -0,0 +1,767 @@ +namespace FSharp.Control.R3.Tests + +open System +open System.Threading +open System.Threading.Tasks +open Microsoft.Extensions.Time.Testing +open Microsoft.VisualStudio.TestTools.UnitTesting +open R3 +open FSharp.Control.R3 +open FSharp.Control.R3.Tests.TestHelpers + +/// Assertions and building blocks shared by the chunk tests. +[] +module private ChunkTestHelpers = + + /// + /// Compares the chunks one by one, because + /// compares nested arrays by reference. + /// + let assertChunks (expected : 'T array array) (actual : 'T array array) (message : string) = + Assert.HasCount (expected.Length, actual, message) + + expected + |> Array.iteri (fun index chunk -> CollectionAssert.AreEqual (chunk, actual[index], $"%s{message} (chunk %d{index})")) + + /// Asserts the whole outcome of a chunked sequence that completed successfully without reporting an error. + let assertCompletedWith (expected : 'T array array) (recorder : Recorder<'T array>) (message : string) = + assertChunks expected recorder.Values message + Assert.IsEmpty (recorder.Errors, "The chunked sequence must not report an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "The chunked sequence must complete successfully") + + /// Asserts that every element opened a window that failed with the error and still emitted that element as a chunk. + let assertEveryWindowFailed (error : exn) (elements : int array) (recorder : Recorder) = + assertChunks (elements |> Array.map Array.singleton) recorder.Values "Every failed window must still emit its chunk" + Assert.HasCount (elements.Length, recorder.Errors, "Every window failure must be reported once") + + for reported in recorder.Errors do + Assert.AreSame (error, reported, "The original exception must be reported, without a wrapper") + + Assert.IsTrue (recorder.IsCompletedSuccessfully, "A window failure must not terminate the chunked sequence") + + /// + /// A window that runs until the test releases + /// the gate of the element that opened it. + /// + let gatedWindow (selector : GatedSelector) = + Func(fun value cancellationToken -> ValueTask (selector.InvokeTask cancellationToken value :> Task)) + + /// + /// Every chunking function, configured so that a chunk closes only when it holds two elements or when the source + /// terminates: the test never advances the fake time, the window never finishes and the boundaries never emit. + /// + let everyChunker (time : TimeProvider) (boundaries : Observable) : struct (string * (Observable -> Observable)) array = + // A window that never finishes leaves its elements to the flush on completion + let endlessWindow = + Func(fun _ _ -> ValueTask ((TaskCompletionSource ()).Task)) + + [| + struct ("chunkBySize", Observable.chunkBySize 2) + struct ("chunkBy ChunkCount", Observable.chunkBy (ChunkCount 2)) + struct ("chunkBy ChunkTimeSpan", Observable.chunkBy (ChunkTimeSpan (TimeSpan.FromSeconds 1., time))) + struct ("chunkBy ChunkTimeSpanCount", Observable.chunkBy (ChunkTimeSpanCount (TimeSpan.FromSeconds 1., 2, time))) + struct ("chunkBy ChunkMilliseconds", Observable.chunkBy (ChunkMilliseconds (1000, time))) + struct ("chunkBy ChunkMillisecondsCount", Observable.chunkBy (ChunkMillisecondsCount (1000, 2, time))) + struct ("chunkBy ChunkAsyncWindow", Observable.chunkBy (ChunkAsyncWindow (endlessWindow, true))) + struct ("chunkBy ChunkWindowBoundaries", Observable.chunkBy (ChunkWindowBoundaries boundaries)) + struct ("chunkByBoundaries", Observable.chunkByBoundaries boundaries) + |] + +[] +type ChunkTests (testContext : TestContext) = + + // Behaviour that every chunking function shares + + [] + member _.``every chunking function emits no chunk for an empty source and completes successfully`` () = + let time = FakeTimeProvider () + use boundaries = new Subject () + + for struct (name, chunk) in everyChunker time boundaries do + // Empty completes during Subscribe, and no R3 chunk operator flushes an empty buffer + use recorder = + (Observable.empty () : Observable) + |> chunk + |> Recorder.Attach + Assert.IsEmpty (recorder.Values, $"%s{name} must not emit a chunk for an empty source") + Assert.IsEmpty (recorder.Errors, $"%s{name} must not report an error for an empty source") + Assert.IsTrue (recorder.IsCompletedSuccessfully, $"%s{name} must complete when the empty source completes") + + [] + member _.``every chunking function forwards OnErrorResume of the source and keeps buffering`` () = + let boom : exn = InvalidOperationException "boom" + let time = FakeTimeProvider () + use boundaries = new Subject () + + for struct (name, chunk) in everyChunker time boundaries do + // resumingError emits 1, reports the error, emits 2 and completes, all during Subscribe + use recorder = Sources.resumingError boom |> chunk |> Recorder.Attach + assertChunks [| [| 1; 2 |] |] recorder.Values $"%s{name} must keep the elements around the error in one chunk" + let error = Assert.ContainsSingle (recorder.Errors, $"%s{name} must forward the error once") + Assert.AreSame (boom, error, $"%s{name} must forward the original error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, $"%s{name} must not terminate on a resumed error") + + [] + member _.``every chunking function flushes the buffered elements before forwarding a source failure`` () = + let boom : exn = InvalidOperationException "boom" + let time = FakeTimeProvider () + use boundaries = new Subject () + + for struct (name, chunk) in everyChunker time boundaries do + // Every R3 chunk operator flushes its buffer on completion, whatever the result + use recorder = + Sources.failingAfter [| 1 |] boom + |> chunk + |> Recorder.Attach + assertChunks [| [| 1 |] |] recorder.Values $"%s{name} must flush the buffered element before the failure" + Assert.IsEmpty (recorder.Errors, $"%s{name} must not report the terminal failure as a resumed error") + assertFailedWith boom recorder $"%s{name} must forward the failure of the source" + + // chunkBySize + + [] + member _.``chunkBySize emits each chunk as soon as it is full and flushes the remainder on completion`` () = + use subject = new Subject () + use recorder = subject |> Observable.chunkBySize 2 |> Recorder.Attach + + // Subject and Chunk(count) deliver synchronously on the calling thread + subject.OnNext 1 + Assert.IsEmpty (recorder.Values, "A chunk must not be emitted before it is full") + subject.OnNext 2 + assertChunks [| [| 1; 2 |] |] recorder.Values "A chunk must be emitted as soon as it is full" + subject.OnNext 3 + subject.OnCompleted Result.Success + + assertCompletedWith [| [| 1; 2 |]; [| 3 |] |] recorder "Completion must flush the partial chunk" + + [] + member _.``chunkBySize emits no empty chunk when the length of the source is a multiple of the size`` () = + use recorder = + Sources.values [| 1..4 |] + |> Observable.chunkBySize 2 + |> Recorder.Attach + + assertCompletedWith [| [| 1; 2 |]; [| 3; 4 |] |] recorder "Completion must not add an empty chunk after full chunks" + + [] + member _.``chunkBySize flushes the partial chunk before forwarding a failure`` () = + let boom : exn = InvalidOperationException "boom" + use recorder = + Sources.failingAfter [| 1; 2; 3 |] boom + |> Observable.chunkBySize 2 + |> Recorder.Attach + + assertChunks [| [| 1; 2 |]; [| 3 |] |] recorder.Values "R3 must flush the partial chunk on a failed completion too" + Assert.IsEmpty (recorder.Errors, "The terminal failure must not be reported as a resumed error") + assertFailedWith boom recorder "The failure of the source must be forwarded" + + [] + member _.``chunkBySize rejects a non-positive size when called, before subscribing`` (size : int) = + // The library validates the size itself: R3 would also reject it, but with its message as the parameter name and no value + assertArgumentRejected (Observable.chunkBySize size) "chunkSize" size "A non-positive chunk size must be rejected when chunkBySize is called" + + // chunkBy ChunkCount + + [] + member _.``chunkBy ChunkCount splits the source into chunks of the count and flushes the remainder`` () = + use recorder = + Sources.values [| 1..5 |] + |> Observable.chunkBy (ChunkCount 2) + |> Recorder.Attach + + assertCompletedWith [| [| 1; 2 |]; [| 3; 4 |]; [| 5 |] |] recorder "ChunkCount must chunk like chunkBySize" + + [] + member _.``chunkBy ChunkCount rejects a non-positive count when called, before subscribing`` (count : int) = + assertArgumentRejected + (Observable.chunkBy (ChunkCount count)) + "configuration" + count + "A non-positive count must be rejected when chunkBy is called" + + // chunkBy ChunkTimeSpan + + [] + member _.``chunkBy ChunkTimeSpan emits the chunk when the window opened by its first element elapses`` () = + let time = FakeTimeProvider () + use subject = new Subject () + use recorder = + subject + |> Observable.chunkBy (ChunkTimeSpan (TimeSpan.FromSeconds 3., time)) + |> Recorder.Attach + + // R3 starts the window timer at the first element of a window, so idle time opens no window + time.Advance (TimeSpan.FromSeconds 10.) + subject.OnNext 1 + time.Advance (TimeSpan.FromSeconds 2.) + subject.OnNext 2 + Assert.IsEmpty (recorder.Values, "No chunk may be emitted before the window elapses") + // FakeTimeProvider fires the due timer synchronously inside Advance + time.Advance (TimeSpan.FromSeconds 1.) + assertChunks [| [| 1; 2 |] |] recorder.Values "The window must emit its elements 3 seconds after its first element" + subject.OnNext 3 + subject.OnCompleted Result.Success + + assertCompletedWith [| [| 1; 2 |]; [| 3 |] |] recorder "Completion must flush the window opened by the last element" + + [] + member _.``chunkBy ChunkTimeSpan never emits an empty chunk while nothing is buffered`` () = + let time = FakeTimeProvider () + use subject = new Subject () + use recorder = + subject + |> Observable.chunkBy (ChunkTimeSpan (TimeSpan.FromSeconds 3., time)) + |> Recorder.Attach + + time.Advance (TimeSpan.FromHours 1.) + Assert.IsEmpty (recorder.Values, "No window may elapse before the first element") + subject.OnNext 1 + time.Advance (TimeSpan.FromSeconds 3.) + // The window timer fires once, and the next one starts only with the next element + time.Advance (TimeSpan.FromHours 1.) + subject.OnCompleted Result.Success + + assertCompletedWith [| [| 1 |] |] recorder "Only the window of the single element may emit a chunk" + + // chunkBy ChunkTimeSpanCount + + [] + member _.``chunkBy ChunkTimeSpanCount emits on reaching the count and the next element opens a new window`` () = + let time = FakeTimeProvider () + use subject = new Subject () + + use recorder = + subject + |> Observable.chunkBy (ChunkTimeSpanCount (TimeSpan.FromSeconds 3., 2, time)) + |> Recorder.Attach + + subject.OnNext 1 + time.Advance (TimeSpan.FromSeconds 2.) + subject.OnNext 2 + assertChunks [| [| 1; 2 |] |] recorder.Values "A full chunk must be emitted as soon as the count is reached" + // Reaching the count stops the window timer, which would otherwise emit an empty chunk at its due time + time.Advance (TimeSpan.FromSeconds 2.) + assertChunks [| [| 1; 2 |] |] recorder.Values "The stopped window of the full chunk must not emit" + subject.OnNext 3 + time.Advance (TimeSpan.FromSeconds 2.) + assertChunks [| [| 1; 2 |] |] recorder.Values "The next window must start with its own first element" + time.Advance (TimeSpan.FromSeconds 1.) + assertChunks [| [| 1; 2 |]; [| 3 |] |] recorder.Values "A window that elapses before the count is reached must emit the partial chunk" + subject.OnNext 4 + subject.OnCompleted Result.Success + + assertCompletedWith [| [| 1; 2 |]; [| 3 |]; [| 4 |] |] recorder "Completion must flush the partial chunk" + + [] + member _.``chunkBy ChunkTimeSpanCount rejects a non-positive count when called, before subscribing`` (count : int) = + let time = FakeTimeProvider () + + // R3 does not validate this count: 0 would fail every element and a negative count would fail only at subscription + assertArgumentRejected + (Observable.chunkBy (ChunkTimeSpanCount (TimeSpan.FromSeconds 1., count, time))) + "configuration" + count + "A non-positive count must be rejected when chunkBy is called" + + // chunkBy ChunkMilliseconds + + [] + member _.``chunkBy ChunkMilliseconds emits the chunk exactly when the window of that many milliseconds elapses`` () = + let time = FakeTimeProvider () + use subject = new Subject () + use recorder = + subject + |> Observable.chunkBy (ChunkMilliseconds (1500, time)) + |> Recorder.Attach + + subject.OnNext 1 + subject.OnNext 2 + time.Advance (TimeSpan.FromMilliseconds 1499.) + Assert.IsEmpty (recorder.Values, "The window must still be open 1 ms before its due time") + // FakeTimeProvider fires a timer exactly at its due time, so this proves a window of 1500 milliseconds + time.Advance (TimeSpan.FromMilliseconds 1.) + assertChunks [| [| 1; 2 |] |] recorder.Values "The window must emit its elements 1500 ms after its first element" + subject.OnNext 3 + subject.OnCompleted Result.Success + + assertCompletedWith [| [| 1; 2 |]; [| 3 |] |] recorder "Completion must flush the window opened by the last element" + + // chunkBy ChunkMillisecondsCount + + [] + member _.``chunkBy ChunkMillisecondsCount emits on reaching the count or exactly when the window elapses`` () = + let time = FakeTimeProvider () + use subject = new Subject () + use recorder = + subject + |> Observable.chunkBy (ChunkMillisecondsCount (1500, 2, time)) + |> Recorder.Attach + + subject.OnNext 1 + subject.OnNext 2 + assertChunks [| [| 1; 2 |] |] recorder.Values "A full chunk must be emitted as soon as the count is reached" + subject.OnNext 3 + time.Advance (TimeSpan.FromMilliseconds 1499.) + assertChunks [| [| 1; 2 |] |] recorder.Values "The window of the partial chunk must still be open 1 ms before its due time" + time.Advance (TimeSpan.FromMilliseconds 1.) + assertChunks [| [| 1; 2 |]; [| 3 |] |] recorder.Values "The window must emit the partial chunk exactly when it elapses" + subject.OnCompleted Result.Success + + assertCompletedWith [| [| 1; 2 |]; [| 3 |] |] recorder "Completion must not emit an empty chunk" + + [] + member _.``chunkBy ChunkMillisecondsCount rejects a non-positive count when called, before subscribing`` (count : int) = + let time = FakeTimeProvider () + + assertArgumentRejected + (Observable.chunkBy (ChunkMillisecondsCount (1000, count, time))) + "configuration" + count + "A non-positive count must be rejected when chunkBy is called" + + // chunkBy ChunkAsyncWindow + + [] + member _.``chunkBy ChunkAsyncWindow opens a window at the first element and emits the chunk when the window finishes`` () : Task = task { + use subject = new Subject () + let window = GatedSelector ignore + use recorder = + subject + |> Observable.chunkBy (ChunkAsyncWindow (gatedWindow window, true)) + |> Recorder.Attach + + subject.OnNext 1 + subject.OnNext 2 + subject.OnNext 3 + // R3 opens a window only for an element that arrives while no window runs, so 2 and 3 join the window of 1 + CollectionAssert.AreEqual ([| 1 |], window.Started, "Only the first element may open a window") + Assert.IsEmpty (recorder.Values, "No chunk may be emitted while the window runs") + + window.Release 1 + do! recorder.WaitForValuesAsync (testContext.CancellationToken, 1) + // R3 emits the chunk and closes the window under one lock, so an element pushed now always opens a new window + subject.OnNext 4 + CollectionAssert.AreEqual ([| 1; 4 |], window.Started, "The first element after a window closed must open a new window") + + window.Release 4 + do! recorder.WaitForValuesAsync (testContext.CancellationToken, 2) + subject.OnCompleted Result.Success + + assertCompletedWith [| [| 1; 2; 3 |]; [| 4 |] |] recorder "Every window must emit the elements that arrived while it ran" + } + + [] + member _.``chunkBy ChunkAsyncWindow flushes the open window on completion and cancels the window token`` () = + use subject = new Subject () + let window = GatedSelector ignore + use recorder = + subject + |> Observable.chunkBy (ChunkAsyncWindow (gatedWindow window, true)) + |> Recorder.Attach + + subject.OnNext 1 + subject.OnNext 2 + let token = Assert.ContainsSingle (window.Tokens, "Only the first element may open a window") + Assert.IsFalse (token.IsCancellationRequested, "The window token must stay active while the source runs") + // The window is never released: R3 cancels the window token, flushes the buffer and completes, all inside OnCompleted + subject.OnCompleted Result.Success + + assertCompletedWith [| [| 1; 2 |] |] recorder "Completion must flush the elements of the open window" + Assert.IsTrue (token.IsCancellationRequested, "Completion must cancel the token of the open window") + + [] + member _.``chunkBy ChunkAsyncWindow with a synchronous window emits one chunk per element`` () = + let opened = ResizeArray() + + let synchronousWindow = + Func(fun value _ -> + opened.Add value + ValueTask.CompletedTask + ) + + use recorder = + Sources.values [| 1; 2; 3 |] + |> Observable.chunkBy (ChunkAsyncWindow (synchronousWindow, true)) + |> Recorder.Attach + + // A window that completes synchronously closes inside OnNext, so every element opens and closes its own window + CollectionAssert.AreEqual ([| 1; 2; 3 |], opened.ToArray (), "Every element must open a window") + assertCompletedWith [| [| 1 |]; [| 2 |]; [| 3 |] |] recorder "A synchronous window must emit every element alone" + + [] + member _.``chunkBy ChunkAsyncWindow resumes a window failure and still emits the chunk`` () = + let boom : exn = InvalidOperationException "boom" + let failingWindow = + Func(fun _ _ -> ValueTask (Task.FromException boom)) + + use recorder = + Sources.values [| 1; 2 |] + |> Observable.chunkBy (ChunkAsyncWindow (failingWindow, true)) + |> Recorder.Attach + + // R3 reports a window exception through OnErrorResume and still emits the chunk when the window ends + assertEveryWindowFailed boom [| 1; 2 |] recorder + + [] + member _.``disposing a chunkBy ChunkAsyncWindow subscription cancels the window token and unsubscribes from the source`` () = + use subject = new Subject () + let probe = SubscriptionProbe () + let window = GatedSelector ignore + + use recorder = + subject + |> probe.Watch + |> Observable.chunkBy (ChunkAsyncWindow (gatedWindow window, true)) + |> Recorder.Attach + + subject.OnNext 1 + let token = Assert.ContainsSingle (window.Tokens, "The first element must open a window") + Assert.IsFalse (token.IsCancellationRequested, "The window token must stay active until the subscription is disposed") + recorder.Dispose () + + Assert.IsTrue (token.IsCancellationRequested, "Disposing must cancel the token of the running window") + // The subject never completes by itself, so this disposal proves the unsubscription + Assert.AreEqual (1, probe.Disposed, "Disposing must unsubscribe from the source") + // Nothing is asserted on the recorder: disposing only cancels the window token, R3 keeps the buffer and would still + // emit it when the window ends, and only the disposed recorder, which ignores every notification, discards it + + [] + member _.``chunkBy ChunkAsyncWindow resumes the window on the captured synchronization context only when ConfigureAwait is true`` + (configureAwait : bool) + : Task + = task { + use subject = new Subject () + // The gate resumes its awaiter asynchronously, so only ConfigureAwait decides where the window continuation runs + let gate = TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously) + let window = Func(fun _ _ -> ValueTask gate.Task) + use recorder = + subject + |> Observable.chunkBy (ChunkAsyncWindow (window, configureAwait)) + |> Recorder.Attach + let context = RecordingSynchronizationContext () + + // R3 awaits the window inside OnNext, so the window captures the context that is current when the element arrives + context.Run (fun () -> subject.OnNext 1) + gate.SetResult () + do! recorder.WaitForValuesAsync (testContext.CancellationToken, 1) + + assertChunks [| [| 1 |] |] recorder.Values "The window must emit its element when it finishes" + let expectedPosts = if configureAwait then 1 else 0 + Assert.AreEqual (expectedPosts, context.Posts, "Only a window awaited with ConfigureAwait may resume on the captured context") + } + + // chunkBy ChunkWindowBoundaries + + [] + member _.``chunkBy ChunkWindowBoundaries emits the buffer on every boundary, including empty chunks`` () = + use subject = new Subject () + use boundaries = new Subject () + use recorder = + subject + |> Observable.chunkBy (ChunkWindowBoundaries boundaries) + |> Recorder.Attach + + subject.OnNext 1 + subject.OnNext 2 + Assert.IsEmpty (recorder.Values, "Elements must be buffered until a boundary arrives") + boundaries.OnNext 0 + // R3 emits an empty chunk for a boundary that finds nothing buffered + boundaries.OnNext 0 + subject.OnNext 3 + subject.OnCompleted Result.Success + + assertCompletedWith [| [| 1; 2 |]; [||]; [| 3 |] |] recorder "Every boundary and the completion must emit the buffer" + + [] + member _.``chunkBy ChunkWindowBoundaries forwards a boundary OnErrorResume and keeps chunking`` () = + let boom : exn = InvalidOperationException "boom" + use subject = new Subject () + use boundaries = new Subject () + use recorder = + subject + |> Observable.chunkBy (ChunkWindowBoundaries boundaries) + |> Recorder.Attach + + subject.OnNext 1 + // R3 reports an error of the boundaries through the chunked sequence instead of terminating it + boundaries.OnErrorResume boom + subject.OnNext 2 + boundaries.OnNext 0 + + let error = + Assert.ContainsSingle (recorder.Errors, "The error of the boundaries must be forwarded once") + Assert.AreSame (boom, error, "The original error of the boundaries must be forwarded") + assertChunks [| [| 1; 2 |] |] recorder.Values "The error of the boundaries must not close the chunk" + Assert.IsTrue (recorder.Completion.IsNone, "The error of the boundaries must not complete the chunked sequence") + + [] + member _.``chunkBy ChunkWindowBoundaries flushes and completes successfully when the boundaries fail`` () = + let boom : exn = InvalidOperationException "boom" + use subject = new Subject () + use boundaries = new Subject () + let probe = SubscriptionProbe () + + use recorder = + subject + |> probe.Watch + |> Observable.chunkBy (ChunkWindowBoundaries boundaries) + |> Recorder.Attach + + subject.OnNext 1 + // R3 completes the chunked sequence with Success whatever the result of the boundaries, so their failure is lost + boundaries.OnCompleted (Result.Failure boom) + + assertChunks [| [| 1 |] |] recorder.Values "The completion of the boundaries must flush the buffer" + Assert.IsTrue (recorder.IsCompletedSuccessfully, "A failure of the boundaries must complete the chunked sequence successfully") + Assert.IsEmpty (recorder.Errors, "A failure of the boundaries must not be reported as an error either") + // The subject never completes by itself, so this disposal proves the unsubscription + Assert.AreEqual (1, probe.Disposed, "The completed chunked sequence must unsubscribe from the source") + + [] + member _.``disposing a chunkBy ChunkWindowBoundaries subscription unsubscribes from the source and the boundaries`` () = + use subject = new Subject () + use boundaries = new Subject () + let sourceProbe = SubscriptionProbe () + let boundariesProbe = SubscriptionProbe () + + use recorder = + subject + |> sourceProbe.Watch + |> Observable.chunkBy (ChunkWindowBoundaries (boundariesProbe.Watch boundaries)) + |> Recorder.Attach + + Assert.AreEqual (1, sourceProbe.Active, "Subscribing must subscribe to the source") + Assert.AreEqual (1, boundariesProbe.Active, "Subscribing must subscribe to the boundaries") + subject.OnNext 1 + recorder.Dispose () + + // Neither subject completes by itself, so these disposals prove the unsubscriptions + Assert.AreEqual (0, sourceProbe.Active, "Disposing must unsubscribe from the source") + Assert.AreEqual (0, boundariesProbe.Active, "Disposing must unsubscribe from the boundaries") + + // chunkByBoundaries + + [] + member _.``chunkByBoundaries chunks on every tick of boundaries of another element type`` () = + use subject = new Subject () + use ticks = new Subject () + use recorder = + subject + |> Observable.chunkByBoundaries ticks + |> Recorder.Attach + + subject.OnNext 1 + subject.OnNext 2 + ticks.OnNext () + ticks.OnNext () + subject.OnNext 3 + subject.OnCompleted Result.Success + + assertCompletedWith [| [| 1; 2 |]; [||]; [| 3 |] |] recorder "Every tick and the completion must emit the buffer" + + [] + member _.``chunkByBoundaries chunks on the ticks of Observable.Interval driven by fake time`` () = + let time = FakeTimeProvider () + use subject = new Subject () + + use recorder = + subject + |> Observable.chunkByBoundaries (Observable.Interval (TimeSpan.FromSeconds 1., time)) + |> Recorder.Attach + + subject.OnNext 1 + subject.OnNext 2 + // The periodic timer of Interval fires synchronously inside Advance + time.Advance (TimeSpan.FromSeconds 1.) + assertChunks [| [| 1; 2 |] |] recorder.Values "The first tick must emit the elements buffered before it" + time.Advance (TimeSpan.FromSeconds 1.) + subject.OnNext 3 + time.Advance (TimeSpan.FromSeconds 1.) + subject.OnCompleted Result.Success + + assertCompletedWith [| [| 1; 2 |]; [||]; [| 3 |] |] recorder "Every tick must emit a chunk, an empty one when nothing is buffered" + + [] + member _.``chunkByBoundaries forwards an error of string boundaries and completes successfully when they fail`` () = + let boom : exn = InvalidOperationException "boom" + use subject = new Subject () + use boundaries = new Subject () + let probe = SubscriptionProbe () + use recorder = + subject + |> probe.Watch + |> Observable.chunkByBoundaries boundaries + |> Recorder.Attach + + subject.OnNext 1 + boundaries.OnErrorResume boom + boundaries.OnNext "close" + subject.OnNext 2 + // R3 completes the chunked sequence with Success whatever the result of the boundaries + boundaries.OnCompleted (Result.Failure (TimeoutException "the boundaries failed")) + + assertChunks [| [| 1 |]; [| 2 |] |] recorder.Values "The tick and the completion of the boundaries must emit the buffer" + let error = + Assert.ContainsSingle (recorder.Errors, "Only the resumed error of the boundaries may be forwarded") + Assert.AreSame (boom, error, "The original error of the boundaries must be forwarded") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "A failure of the boundaries must complete the chunked sequence successfully") + // The subject never completes by itself, so this disposal proves the unsubscription + Assert.AreEqual (1, probe.Disposed, "The completed chunked sequence must unsubscribe from the source") + + // ChunkConfiguration helpers + + [] + member _.``ChunkConfiguration TimeSpan and TimeSpanCount keep their arguments and capture the default time provider`` () = + let windowTime = TimeSpan.FromSeconds 3. + // Only read: the default time provider is process-wide and this test runs in parallel with the others. It is the + // system provider here, so only the test that replaces it, run apart from the parallel ones, tells the two apart + let defaultTimeProvider = ObservableSystem.DefaultTimeProvider + + match (ChunkConfiguration.TimeSpan windowTime : ChunkConfiguration) with + | ChunkTimeSpan (actualWindowTime, timeProvider) -> + Assert.AreEqual (windowTime, actualWindowTime, "TimeSpan must keep the window time") + Assert.AreSame (defaultTimeProvider, timeProvider, "TimeSpan must capture the default time provider") + | other -> Assert.Fail $"TimeSpan must build ChunkTimeSpan, not %A{other}" + + match (ChunkConfiguration.TimeSpanCount windowTime 4 : ChunkConfiguration) with + | ChunkTimeSpanCount (actualWindowTime, windowLength, timeProvider) -> + Assert.AreEqual (windowTime, actualWindowTime, "TimeSpanCount must keep the window time") + Assert.AreEqual (4, windowLength, "TimeSpanCount must keep the window length") + Assert.AreSame (defaultTimeProvider, timeProvider, "TimeSpanCount must capture the default time provider") + | other -> Assert.Fail $"TimeSpanCount must build ChunkTimeSpanCount, not %A{other}" + + [] + member _.``ChunkConfiguration Milliseconds and MillisecondsCount keep their arguments and capture the default time provider`` () = + // Only read: the default time provider is process-wide and this test runs in parallel with the others. It is the + // system provider here, so only the test that replaces it, run apart from the parallel ones, tells the two apart + let defaultTimeProvider = ObservableSystem.DefaultTimeProvider + + match (ChunkConfiguration.Milliseconds 1500 : ChunkConfiguration) with + | ChunkMilliseconds (windowTime, timeProvider) -> + Assert.AreEqual (1500, windowTime, "Milliseconds must keep the window time") + Assert.AreSame (defaultTimeProvider, timeProvider, "Milliseconds must capture the default time provider") + | other -> Assert.Fail $"Milliseconds must build ChunkMilliseconds, not %A{other}" + + match (ChunkConfiguration.MillisecondsCount 1500 4 : ChunkConfiguration) with + | ChunkMillisecondsCount (windowTime, windowLength, timeProvider) -> + Assert.AreEqual (1500, windowTime, "MillisecondsCount must keep the window time") + Assert.AreEqual (4, windowLength, "MillisecondsCount must keep the window length") + Assert.AreSame (defaultTimeProvider, timeProvider, "MillisecondsCount must capture the default time provider") + | other -> Assert.Fail $"MillisecondsCount must build ChunkMillisecondsCount, not %A{other}" + + [] + member _.``ChunkConfiguration time helpers capture a replaced default time provider when called and chunk by its timers`` () = + let time = FakeTimeProvider () + let windowMilliseconds = 3000 + let windowTime = TimeSpan.FromMilliseconds (float windowMilliseconds) + // The system provider is the initial default, so only a replaced default tells helpers that read the default apart + // from helpers that use TimeProvider.System directly. The default is process-wide: DoNotParallelize makes MSTest run + // this test after the parallel ones, so none of them can see the fake, and the finally restores the previous default + // for the tests that run after this one, even when a helper throws + let previous = ObservableSystem.DefaultTimeProvider + ObservableSystem.DefaultTimeProvider <- time + + let configurations : struct (string * ChunkConfiguration) array = + try + [| + struct ("TimeSpan", ChunkConfiguration.TimeSpan windowTime) + struct ("TimeSpanCount", ChunkConfiguration.TimeSpanCount windowTime 2) + struct ("Milliseconds", ChunkConfiguration.Milliseconds windowMilliseconds) + struct ("MillisecondsCount", ChunkConfiguration.MillisecondsCount windowMilliseconds 2) + |] + finally + ObservableSystem.DefaultTimeProvider <- previous + + for struct (name, configuration) in configurations do + match configuration with + | ChunkTimeSpan (_, timeProvider) + | ChunkTimeSpanCount (_, _, timeProvider) + | ChunkMilliseconds (_, timeProvider) + | ChunkMillisecondsCount (_, _, timeProvider) -> + Assert.AreSame(time, timeProvider, $"%s{name} must capture the default time provider of the moment it is called") + | other -> Assert.Fail $"%s{name} must build a time based configuration, not %A{other}" + + // The previous default is back and a single element stays below the count of 2, so only a timer of the captured + // fake can emit this chunk; FakeTimeProvider fires the due timer synchronously inside Advance + use subject = new Subject () + use recorder = + subject + |> Observable.chunkBy configuration + |> Recorder.Attach + subject.OnNext 1 + time.Advance windowTime + assertChunks [| [| 1 |] |] recorder.Values $"%s{name} must emit the chunk when the fake time reaches the end of the window" + + [] + member _.``ChunkConfiguration time helpers chunk a synchronous source by count and on completion before any system timer fires`` () = + // The helpers capture the default time provider, the system one here. The source is synchronous, so completion + // flushes long before a one-hour timer could fire, and R3 disposes the timer when the chunked sequence completes: + // never use a hot source here + let hour = TimeSpan.FromHours 1. + let millisecondsPerHour = 3_600_000 + + let cases = [| + struct ("TimeSpanCount", ChunkConfiguration.TimeSpanCount hour 2, [| [| 1; 2 |]; [| 3; 4 |]; [| 5 |] |]) + struct ("TimeSpan", ChunkConfiguration.TimeSpan hour, [| [| 1; 2; 3; 4; 5 |] |]) + struct ("MillisecondsCount", ChunkConfiguration.MillisecondsCount millisecondsPerHour 2, [| [| 1; 2 |]; [| 3; 4 |]; [| 5 |] |]) + struct ("Milliseconds", ChunkConfiguration.Milliseconds millisecondsPerHour, [| [| 1; 2; 3; 4; 5 |] |]) + |] + + for struct (name, configuration, expected) in cases do + use recorder = + Sources.values [| 1..5 |] + |> Observable.chunkBy configuration + |> Recorder.Attach + assertChunks expected recorder.Values $"%s{name} must chunk by count and flush the rest on completion" + Assert.IsEmpty (recorder.Errors, $"%s{name} must not report an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, $"%s{name} must complete with the source") + + [] + member _.``ChunkConfiguration AsyncWindow builds ChunkAsyncWindow with ConfigureAwait enabled`` () = + match ChunkConfiguration.AsyncWindow (fun (_ : int) -> async.Zero ()) with + | ChunkAsyncWindow (_, configureAwait) -> + Assert.IsTrue (configureAwait, "AsyncWindow must capture the synchronization context, as R3 does by default") + | other -> Assert.Fail $"AsyncWindow must build ChunkAsyncWindow, not %A{other}" + + [] + member _.``ChunkConfiguration AsyncWindow passes the window token to the Async and closes the window when the Async finishes`` () : Task = task { + use subject = new Subject () + // InvokeAsync records the token of the Async computation and waits for the gate of its element + let window = GatedSelector ignore + use recorder = + subject + |> Observable.chunkBy (ChunkConfiguration.AsyncWindow window.InvokeAsync) + |> Recorder.Attach + + subject.OnNext 1 + subject.OnNext 2 + CollectionAssert.AreEqual ([| 1 |], window.Started, "Only the first element may start the Async window") + let token = Assert.ContainsSingle (window.Tokens, "The Async window must start once") + Assert.IsFalse (token.IsCancellationRequested, "The window token must stay active while the source runs") + + window.Release 1 + do! recorder.WaitForValuesAsync (testContext.CancellationToken, 1) + subject.OnNext 3 + CollectionAssert.AreEqual ([| 1; 3 |], window.Started, "The first element after the Async finished must start a new window") + + window.Release 3 + do! recorder.WaitForValuesAsync (testContext.CancellationToken, 2) + subject.OnCompleted Result.Success + + assertCompletedWith [| [| 1; 2 |]; [| 3 |] |] recorder "Every Async window must emit the elements that arrived while it ran" + // R3 cancels its window token when the chunked sequence completes, so this proves the Async ran with that token + Assert.IsTrue (token.IsCancellationRequested, "The Async must have run with the window token of R3") + } + + [] + member _.``ChunkConfiguration AsyncWindow resumes an Async window failure and still emits the chunk`` () = + let boom : exn = InvalidOperationException "boom" + + use recorder = + Sources.values [| 1; 2 |] + |> Observable.chunkBy (ChunkConfiguration.AsyncWindow (fun (_ : int) -> async { return raise boom })) + |> Recorder.Attach + + // StartImmediateAsTask runs the Async synchronously and faults its task with the original exception, + // which R3 reports through OnErrorResume before it emits the chunk of the window + assertEveryWindowFailed boom [| 1; 2 |] recorder diff --git a/tests/FSharp.Control.R3.Tests/FSharp.Control.R3.Tests.fsproj b/tests/FSharp.Control.R3.Tests/FSharp.Control.R3.Tests.fsproj index c087917..eca53d1 100644 --- a/tests/FSharp.Control.R3.Tests/FSharp.Control.R3.Tests.fsproj +++ b/tests/FSharp.Control.R3.Tests/FSharp.Control.R3.Tests.fsproj @@ -13,11 +13,19 @@ - + + + + + + + + + diff --git a/tests/FSharp.Control.R3.Tests/IntegrationTests.fs b/tests/FSharp.Control.R3.Tests/IntegrationTests.fs new file mode 100644 index 0000000..c6fef56 --- /dev/null +++ b/tests/FSharp.Control.R3.Tests/IntegrationTests.fs @@ -0,0 +1,808 @@ +namespace FSharp.Control.R3.Tests + +open System +open System.Linq +open System.Threading +open System.Threading.Tasks +open Microsoft.Extensions.Time.Testing +open Microsoft.VisualStudio.TestTools.UnitTesting +open R3 +open Swensen.Unquote +open FSharp.Control.R3 +open FSharp.Control.R3.Observable.Builders +open FSharp.Control.R3.Tests.TestHelpers + +// The scenarios combine both flavours, whose modules must never be opened in the same file, so they are reached through abbreviations. +// The abbreviations that TestHelpers declares are local to that file, so this file declares its own +module TaskObservable = FSharp.Control.R3.Task.Observable +module AsyncObservable = FSharp.Control.R3.Async.Observable + +// The type abbreviations live in a module named after this file rather than directly in the namespace: a namespace-level type +// is shared by every file of the assembly, so another test file that declared the same names would fail to compile with FS0249. +// The module is opened right below rather than AutoOpen, which would also open it in every later file of the namespace +module internal IntegrationTestsSupport = + + /// The overloaded functions of the Task flavour. + type TaskConversions = FSharp.Control.R3.Task.Extensions.Observable + + /// The overloaded functions of the Async flavour. + type AsyncConversions = FSharp.Control.R3.Async.Extensions.Observable + + /// The asynchronous factory of the flavour that emits the result of the gated selector for the value. + let factoryWith (flavour : string) (selector : GatedSelector<'T, 'R>) (value : 'T) : Observable<'R> = + match flavour with + | "Task" -> TaskConversions.ofTask (fun cancellationToken -> ValueTask<'R>(selector.InvokeTask cancellationToken value)) + | "Async" -> AsyncObservable.ofAsync (selector.InvokeAsync value) + | _ -> Flavour.unknown flavour + + /// + /// Starts + /// or , as the flavour says; + /// the Async flavour runs with the token as the token of its computation. + /// + let toListWith (cancellationToken : CancellationToken) (flavour : string) (source : Observable<'T>) : Task<'T list> = + match flavour with + | "Task" -> TaskObservable.toList cancellationToken source + | "Async" -> + AsyncObservable.toList source + |> AsyncTest.start cancellationToken + | _ -> Flavour.unknown flavour + + /// The groups of the lookup as pairs of key and elements, ordered by key, so that two lookups compare structurally. + let groupsOf (lookup : ILookup<'Key, 'Element>) = + lookup + |> Seq.map (fun group -> struct (group.Key, Seq.toArray group)) + |> Seq.sortBy (fun struct (key, _) -> key) + |> Seq.toArray + +open IntegrationTestsSupport + +[] +type IntegrationTests (testContext : TestContext) = + + // Keeps the elements that satisfy the predicate, drops the repeated ones, pairs every element with its index and chunks the pairs by two + let distinctIndexedPairs (predicate : int -> bool) (source : Observable) = + source + |> Observable.filter predicate + |> Observable.distinct + |> Observable.mapi (fun index value -> struct (index, value)) + |> Observable.chunkBySize 2 + + // Merges both sources, projects their elements and falls back to -1 when a source fails with an InvalidOperationException + let catchProtected (project : int -> int) (handled : ResizeArray) (left : Observable) (right : Observable) = + Observable.merge (left, right) + |> Observable.map project + |> Observable.catch (fun (error : InvalidOperationException) -> + handled.Add error + Observable.singleton (-1) + ) + + // The sum of a chunk, or None for the empty chunk that a boundary emits when nothing is buffered + let sumOfNonEmpty (chunk : int array) = + if Array.isEmpty chunk then + ValueNone + else + ValueSome (Array.sum chunk) + + [] + member _.``filter, distinct, mapi and chunkBySize compose over a hot subject`` () = + use subject = new Subject () + // A subject is hot: what it receives before the pipeline subscribes is lost + subject.OnNext 9 + + use recorder = + subject + |> distinctIndexedPairs (fun x -> x > 0) + |> Recorder.Attach + + subject.OnNext 3 + subject.OnNext (-1) + subject.OnNext 3 + subject.OnNext 5 + // Every operator delivers synchronously, so the first chunk is emitted as soon as its second element arrives + let firstChunk = [| [| struct (0, 3); struct (1, 5) |] |] + test <@ recorder.Values = firstChunk @> + subject.OnNext 7 + Assert.IsTrue (recorder.Completion.IsNone, "The pipeline must stay open while the subject is open") + + subject.OnCompleted Result.Success + + // Completion flushes the partial chunk, and the index counts only the elements that passed filter and distinct + let chunks = [| [| struct (0, 3); struct (1, 5) |]; [| struct (2, 7) |] |] + test <@ recorder.Values = chunks @> + Assert.IsEmpty (recorder.Errors, "No error may be resumed") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "The pipeline must complete successfully with the subject") + + [] + member _.``a failure of the hot subject flushes the partial chunk of the pipeline before failing it`` () = + use subject = new Subject () + let boom : exn = InvalidOperationException "boom" + + use recorder = + subject + |> distinctIndexedPairs (fun x -> x > 0) + |> Recorder.Attach + + subject.OnNext 3 + subject.OnNext 5 + subject.OnNext 7 + subject.OnCompleted (Result.Failure boom) + + // R3 chunking flushes the partial chunk on a failed completion too, before it forwards the failure + let chunks = [| [| struct (0, 3); struct (1, 5) |]; [| struct (2, 7) |] |] + test <@ recorder.Values = chunks @> + assertFailedWith boom recorder "The pipeline must fail with the exception of the subject" + + [] + member _.``an error resumed by the filter passes through distinct, mapi and chunkBySize without taking an index`` () = + use subject = new Subject () + let boom : exn = InvalidOperationException "boom" + + use recorder = + subject + |> distinctIndexedPairs (fun x -> if x = 4 then raise boom else x > 0) + |> Recorder.Attach + + subject.OnNext 3 + // R3 reports the exception of the predicate through OnErrorResume, which every following operator forwards unchanged + subject.OnNext 4 + subject.OnNext 5 + subject.OnNext 7 + subject.OnCompleted Result.Success + + let chunks = [| [| struct (0, 3); struct (1, 5) |]; [| struct (2, 7) |] |] + test <@ recorder.Values = chunks @> + let resumed = Assert.ContainsSingle (recorder.Errors, "Exactly one error must be resumed") + Assert.AreSame (boom, resumed, "The resumed error must be the exception of the predicate") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "A resumed error must not stop the pipeline") + + [] + member _.``disposing a multi-operator pipeline unsubscribes it from the hot subject`` () = + use subject = new Subject () + let probe = SubscriptionProbe () + + use recorder = + subject + |> probe.Watch + |> distinctIndexedPairs (fun x -> x > 0) + |> Recorder.Attach + + subject.OnNext 3 + Assert.AreEqual (1, probe.Active, "The pipeline must hold one subscription to the subject") + + recorder.Dispose () + + // Every operator disposes its upstream subscription synchronously, down to the subject, which never completes by itself + Assert.AreEqual (1, probe.Subscribed, "The pipeline must have subscribed to the subject once") + Assert.AreEqual (1, probe.Disposed, "Disposing the pipeline must unsubscribe it from the subject") + + [] + member _.``merged sources feed a catch-protected pipeline that falls back and unsubscribes from the open source`` () = + use left = new Subject () + use right = new Subject () + let rightProbe = SubscriptionProbe () + let handled = ResizeArray() + let boom : exn = InvalidOperationException "boom" + + use recorder = + catchProtected (fun x -> x * 10) handled left (rightProbe.Watch right) + |> Recorder.Attach + + left.OnNext 1 + right.OnNext 2 + left.OnCompleted (Result.Failure boom) + + // Merge fails as soon as one source fails, and catch subscribes to the fallback, which emits synchronously + CollectionAssert.AreEqual ([| 10; 20; -1 |], recorder.Values, "The values of both sources must be followed by the fallback value") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "The fallback must complete the pipeline successfully") + let handledError = Assert.ContainsSingle (handled, "The handler must be called once") + Assert.AreSame (boom, handledError, "The handler must receive the exception of the failed source") + // Merge only forwards the failure; the downstream observers dispose the merged subscription when they complete. + // The right subject is still open, so this disposal proves the unsubscription + Assert.AreEqual (1, rightProbe.Disposed, "The failure must unsubscribe the pipeline from the source that is still open") + + [] + member _.``a failure the catch handler does not handle fails the merged pipeline and unsubscribes from the open source`` () = + use left = new Subject () + use right = new Subject () + let rightProbe = SubscriptionProbe () + let handled = ResizeArray() + let unexpected : exn = ArgumentException "unexpected" + + use recorder = + catchProtected (fun x -> x * 10) handled left (rightProbe.Watch right) + |> Recorder.Attach + + left.OnNext 1 + left.OnCompleted (Result.Failure unexpected) + + // R3 Catch handles only failures of the exception type of the handler and forwards the others unchanged + CollectionAssert.AreEqual ([| 10 |], recorder.Values, "Only the value emitted before the failure must arrive") + assertFailedWith unexpected recorder "The pipeline must fail with the unhandled exception" + Assert.IsEmpty (handled, "The handler must not be called for an exception of another type") + Assert.AreEqual (1, rightProbe.Disposed, "The failure must unsubscribe the pipeline from the source that is still open") + + [] + member _.``errors resumed by a merged source or by the projection pass through catch without falling back`` () = + use left = new Subject () + use right = new Subject () + let handled = ResizeArray() + // Both errors have the exception type of the handler, to show that only a terminal failure reaches it + let sourceError : exn = InvalidOperationException "source" + let projectionError : exn = InvalidOperationException "projection" + + use recorder = + catchProtected (fun x -> if x = 2 then raise projectionError else x * 10) handled left right + |> Recorder.Attach + + left.OnNext 1 + left.OnErrorResume sourceError + // R3 reports the exception of the projection through OnErrorResume as well + right.OnNext 2 + right.OnNext 3 + left.OnCompleted Result.Success + Assert.IsTrue (recorder.Completion.IsNone, "Merge must wait for the other source to complete") + right.OnCompleted Result.Success + + // Catch forwards OnErrorResume unchanged and the sequence goes on after each resumed error + CollectionAssert.AreEqual ([| 10; 30 |], recorder.Values, "The values around the resumed errors must arrive") + CollectionAssert.AreEqual ([| sourceError; projectionError |], recorder.Errors, "Both errors must be resumed in order") + Assert.IsEmpty (handled, "A resumed error must not reach the handler") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "The pipeline must complete successfully when both sources complete") + + [] + member _.``Async and Task terminal functions agree on the same pipeline`` () : Task = task { + let ct = testContext.CancellationToken + // The numbers from 1 to 20 without the multiples of 3, doubled; the synchronous source makes every run deterministic + let pipeline = + Sources.values [| 1..20 |] + |> Observable.filter (fun x -> x % 3 <> 0) + |> Observable.map (fun x -> x * 2) + + let elements = [| 2; 4; 8; 10; 14; 16; 20; 22; 26; 28; 32; 34; 38; 40 |] + + let! taskLength = TaskObservable.length ct pipeline + let! asyncLength = AsyncObservable.length pipeline + Assert.AreEqual (elements.Length, taskLength, "The Task length must count every element") + Assert.AreEqual (taskLength, asyncLength, "The Async length must agree with the Task length") + + let! taskSum = TaskObservable.aggregate ct 0 (+) pipeline + let! asyncSum = AsyncObservable.aggregate 0 (+) pipeline + Assert.AreEqual (Array.sum elements, taskSum, "The Task aggregate must fold every element") + Assert.AreEqual (taskSum, asyncSum, "The Async aggregate must agree with the Task aggregate") + + let! taskAllEven = TaskObservable.all ct (fun x -> x % 2 = 0) pipeline + let! asyncAllEven = AsyncObservable.all (fun x -> x % 2 = 0) pipeline + Assert.IsTrue (taskAllEven, "The Task all must hold for a predicate that every element satisfies") + Assert.AreEqual (taskAllEven, asyncAllEven, "The Async all must agree with the Task all") + + let! taskAllBelow30 = TaskObservable.all ct (fun x -> x < 30) pipeline + let! asyncAllBelow30 = AsyncObservable.all (fun x -> x < 30) pipeline + Assert.IsFalse (taskAllBelow30, "The Task all must fail for a predicate that some element does not satisfy") + Assert.AreEqual (taskAllBelow30, asyncAllBelow30, "The Async all must agree with the Task all on a failing predicate") + + let! taskExists = TaskObservable.existsAsync ct pipeline + let! asyncExists = AsyncObservable.existsAsync pipeline + Assert.IsTrue (taskExists, "The Task existsAsync must find an element") + Assert.AreEqual (taskExists, asyncExists, "The Async existsAsync must agree with the Task existsAsync") + + let! taskFirst = TaskObservable.firstAsync ct pipeline + let! asyncFirst = AsyncObservable.firstAsync pipeline + Assert.AreEqual (elements[0], taskFirst, "The Task firstAsync must return the first element") + Assert.AreEqual (taskFirst, asyncFirst, "The Async firstAsync must agree with the Task firstAsync") + + let taskSeen = ResizeArray() + let asyncSeen = ResizeArray() + do! TaskObservable.iter ct taskSeen.Add pipeline + do! AsyncObservable.iter asyncSeen.Add pipeline + CollectionAssert.AreEqual (elements, taskSeen.ToArray (), "The Task iter must visit every element in order") + CollectionAssert.AreEqual (taskSeen.ToArray (), asyncSeen.ToArray (), "The Async iter must visit the same elements") + + let! taskArray = TaskObservable.toArray ct pipeline + let! asyncArray = AsyncObservable.toArray pipeline + CollectionAssert.AreEqual (elements, taskArray, "The Task toArray must collect every element in order") + CollectionAssert.AreEqual (taskArray, asyncArray, "The Async toArray must agree with the Task toArray") + + let! taskList = TaskObservable.toList ct pipeline + let! asyncList = AsyncObservable.toList pipeline + Assert.AreEqual(List.ofArray elements, taskList, "The Task toList must collect every element in order") + Assert.AreEqual(taskList, asyncList, "The Async toList must agree with the Task toList") + + // Grouped by the remainder of the division by 8; the elements of a group keep their order of arrival + let! taskLookup = TaskConversions.toLookup (pipeline, (fun x -> x % 8), ct) + let! asyncLookup = AsyncConversions.toLookup (pipeline, (fun x -> x % 8)) + + let expectedGroups = [| + struct (0, [| 8; 16; 32; 40 |]) + struct (2, [| 2; 10; 26; 34 |]) + struct (4, [| 4; 20; 28 |]) + struct (6, [| 14; 22; 38 |]) + |] + + let taskGroups = groupsOf taskLookup + let asyncGroups = groupsOf asyncLookup + test <@ taskGroups = expectedGroups @> + test <@ asyncGroups = taskGroups @> + } + + [] + member _.``Async and Task terminal functions agree on a pipeline that filters out every element`` () : Task = task { + let ct = testContext.CancellationToken + + let pipeline = + Sources.values [| 1..20 |] + |> Observable.filter (fun x -> x > 20) + |> Observable.map (fun x -> x * 2) + + let! taskLength = TaskObservable.length ct pipeline + let! asyncLength = AsyncObservable.length pipeline + Assert.AreEqual (0, taskLength, "The Task length of an empty pipeline must be 0") + Assert.AreEqual (0, asyncLength, "The Async length of an empty pipeline must be 0") + + // R3 returns the seed when there is nothing to fold + let! taskSum = TaskObservable.aggregate ct 7 (+) pipeline + let! asyncSum = AsyncObservable.aggregate 7 (+) pipeline + Assert.AreEqual (7, taskSum, "The Task aggregate of an empty pipeline must return the seed") + Assert.AreEqual (7, asyncSum, "The Async aggregate of an empty pipeline must return the seed") + + let! taskAll = TaskObservable.all ct (fun _ -> false) pipeline + let! asyncAll = AsyncObservable.all (fun _ -> false) pipeline + Assert.IsTrue (taskAll, "The Task all of an empty pipeline must hold vacuously") + Assert.IsTrue (asyncAll, "The Async all of an empty pipeline must hold vacuously") + + let! taskExists = TaskObservable.existsAsync ct pipeline + let! asyncExists = AsyncObservable.existsAsync pipeline + Assert.IsFalse (taskExists, "The Task existsAsync of an empty pipeline must be false") + Assert.IsFalse (asyncExists, "The Async existsAsync of an empty pipeline must be false") + + // R3 FirstAsync fails with "Sequence contains no elements.", which both flavours surface unwrapped + let! taskError = + Assert.ThrowsAsync( + (fun () -> TaskObservable.firstAsync ct pipeline :> Task), + "The Task firstAsync of an empty pipeline must fail" + ) + + let! asyncError = + Assert.ThrowsAsync( + (fun () -> AsyncTest.start ct (AsyncObservable.firstAsync pipeline) :> Task), + "The Async firstAsync of an empty pipeline must fail" + ) + + Assert.AreEqual (taskError.Message, asyncError.Message, "Both flavours must fail with the same message") + + let taskSeen = ResizeArray() + let asyncSeen = ResizeArray() + do! TaskObservable.iter ct taskSeen.Add pipeline + do! AsyncObservable.iter asyncSeen.Add pipeline + Assert.IsEmpty (taskSeen, "The Task iter must not call the action for an empty pipeline") + Assert.IsEmpty (asyncSeen, "The Async iter must not call the action for an empty pipeline") + + let! taskArray = TaskObservable.toArray ct pipeline + let! asyncArray = AsyncObservable.toArray pipeline + Assert.IsEmpty (taskArray, "The Task toArray of an empty pipeline must be empty") + Assert.IsEmpty (asyncArray, "The Async toArray of an empty pipeline must be empty") + + let! taskList = TaskObservable.toList ct pipeline + let! asyncList = AsyncObservable.toList pipeline + Assert.IsEmpty (taskList, "The Task toList of an empty pipeline must be empty") + Assert.IsEmpty (asyncList, "The Async toList of an empty pipeline must be empty") + + let! taskLookup = TaskConversions.toLookup (pipeline, (fun x -> x % 8), ct) + let! asyncLookup = AsyncConversions.toLookup (pipeline, (fun x -> x % 8)) + Assert.IsEmpty (taskLookup, "The Task toLookup of an empty pipeline must have no group") + Assert.IsEmpty (asyncLookup, "The Async toLookup of an empty pipeline must have no group") + } + + [] + member _.``Async and Task terminal functions fail with the same exception when the pipeline fails`` (failure : string) : Task = task { + let ct = testContext.CancellationToken + let boom : exn = InvalidOperationException "boom" + + let source = + match failure with + | "terminal failure" -> Sources.failingAfter [| 1; 2; 3 |] boom + // R3 reports the exception of the projection through OnErrorResume, which a terminal function treats as a failure + | "resumed error" -> + Sources.values [| 1; 2; 3; 4 |] + |> Observable.map (fun x -> if x = 4 then raise boom else x) + | other -> invalidArg (nameof failure) $"Unknown failure %s{other}" + + let pipeline = source |> Observable.map (fun x -> x * 10) + + // The functions that decide on the first element return and unsubscribe before the failure arrives + let! taskExists = TaskObservable.existsAsync ct pipeline + let! asyncExists = AsyncObservable.existsAsync pipeline + let! taskFirst = TaskObservable.firstAsync ct pipeline + let! asyncFirst = AsyncObservable.firstAsync pipeline + Assert.IsTrue (taskExists, "The Task existsAsync must decide on the first element") + Assert.IsTrue (asyncExists, "The Async existsAsync must decide on the first element") + Assert.AreEqual (10, taskFirst, "The Task firstAsync must return the first element") + Assert.AreEqual (10, asyncFirst, "The Async firstAsync must return the first element") + + // The others wait for the end of the sequence and fail with the very exception of the pipeline in both flavours; + // all waits as well, because every element of the pipeline satisfies its predicate + let waitingForTheEnd = + Terminals.names + |> Array.except [| "existsAsync"; "firstAsync" |] + + for name in waitingForTheEnd do + for flavour in [| "Task"; "Async" |] do + let! error = + Assert.ThrowsAsync( + (fun () -> Terminals.runWith ct flavour name pipeline), + $"%s{flavour} %s{name} must fail" + ) + + Assert.AreSame (boom, error, $"%s{flavour} %s{name} must fail with the exception of the pipeline") + } + + [] + member _.``a mapAsync selector failure faults the terminal function and tears down the pipeline`` (flavour : string) : Task = task { + let ct = testContext.CancellationToken + use subject = new Subject () + let probe = SubscriptionProbe () + let boom : exn = InvalidOperationException "boom" + let selector = GatedSelector(fun x -> if x = 2 then raise boom else x * 10) + // Released up front, so both invocations finish as soon as they start + selector.Release 1 + selector.Release 2 + + let elements = + subject + |> probe.Watch + |> Flavour.mapWith flavour ProcessingOptions.Default selector + |> Terminals.runWith ct flavour "toArray" + + subject.OnNext 1 + subject.OnNext 2 + + // mapAsync reports the selector failure through OnErrorResume, which the terminal function turns into its own failure + let! error = + Assert.ThrowsAsync((fun () -> elements), "The terminal function must fail with the selector failure") + + Assert.AreSame (boom, error, "Both flavours must surface the very exception of the selector") + // R3 completes the task of the terminal operator before it disposes the subscription, so the test waits for the disposal + do! probe.WaitForDisposedAsync (ct, 1) + Assert.AreEqual (1, probe.Subscribed, "The pipeline must have subscribed to the subject once") + Assert.AreEqual (1, probe.Disposed, "The failure must unsubscribe the pipeline from the subject") + CollectionAssert.AreEqual ([| 1; 2 |], selector.Started, "The selector must have been invoked for both elements") + } + + [] + member _.``cancelling a terminal function cancels the running mapAsync work and unsubscribes from the source`` (flavour : string) : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + let selector = GatedSelector(fun x -> x * 10) + use cancellation = CancellationTokenSource.CreateLinkedTokenSource testContext.CancellationToken + + // Parallel starts the selector inside OnNext, so an element that still reached the pipeline after the cancellation + // would be in Started at once; the sequential worker would stay parked on the unreleased selector of element 1 instead + let length = + subject + |> probe.Watch + |> Flavour.mapWith flavour ProcessingOptions.Parallel selector + |> Terminals.runWith cancellation.Token flavour "length" + + subject.OnNext 1 + do! selector.WaitForStartedAsync (testContext.CancellationToken, 1) + Assert.IsFalse (length.IsCompleted, "The terminal function must wait while the selector runs") + + // R3 registers on the token before it subscribes; Cancel runs the registration inline, which disposes the subscription + // chain, cancelling the selector token of mapAsync on its way, before it cancels the task + cancellation.Cancel () + + Assert.AreEqual (1, probe.Disposed, "Cancelling must unsubscribe the pipeline from the subject before Cancel returns") + Assert.IsTrue (selector.Tokens[0].IsCancellationRequested, "Cancelling must cancel the token of the running selector") + subject.OnNext 2 + CollectionAssert.AreEqual ([| 1 |], selector.Started, "No element may reach the selector after the cancellation") + + let! _ = + Assert.ThrowsAsync((fun () -> length), "Awaiting a cancelled terminal function must throw") + + Assert.IsTrue (length.IsCanceled, "The task of the terminal function must be cancelled rather than faulted") + } + + [] + member _.``time-chunked elements are aggregated asynchronously in window order`` (flavour : string) : Task = task { + let ct = testContext.CancellationToken + let time = FakeTimeProvider () + use subject = new Subject () + // GatedSelector keys its gates with structural equality, so an equal array releases the aggregation of a chunk + let sum = GatedSelector Array.sum + + use recorder = + subject + |> Observable.chunkBy (ChunkTimeSpan (TimeSpan.FromSeconds 2., time)) + |> Flavour.mapWith flavour ProcessingOptions.Default sum + |> Recorder.Attach + + subject.OnNext 1 + time.Advance (TimeSpan.FromSeconds 1.) + subject.OnNext 2 + // R3 starts the window at its first element, and that window has not elapsed yet + Assert.IsEmpty (sum.Started, "No chunk may be aggregated before its window elapses") + + // FakeTimeProvider fires the window timer synchronously inside Advance + time.Advance (TimeSpan.FromSeconds 1.) + sum.Release [| 1; 2 |] + do! recorder.WaitForValuesAsync (ct, 1) + + subject.OnNext 4 + // Completion flushes the open window, and the sequential mapAsync completes only after its pending aggregation + subject.OnCompleted Result.Success + sum.Release [| 4 |] + let! completion = recorder.WaitForCompletionAsync ct + + CollectionAssert.AreEqual ([| 3; 4 |], recorder.Values, "The sums must arrive in window order") + Assert.IsTrue (completion.IsSuccess, "The aggregation must complete successfully after the last window") + test <@ sum.Started = [| [| 1; 2 |]; [| 4 |] |] @> + } + + [] + member _.``a source failure ends the time-chunked aggregation at once and cancels the pending aggregation`` (flavour : string) : Task = task { + let ct = testContext.CancellationToken + let time = FakeTimeProvider () + use subject = new Subject () + let sum = GatedSelector Array.sum + let boom : exn = InvalidOperationException "boom" + + use recorder = + subject + |> Observable.chunkBy (ChunkTimeSpan (TimeSpan.FromSeconds 2., time)) + |> Flavour.mapWith flavour ProcessingOptions.Default sum + |> Recorder.Attach + + subject.OnNext 1 + subject.OnNext 2 + time.Advance (TimeSpan.FromSeconds 2.) + do! sum.WaitForStartedAsync (ct, 1) + subject.OnNext 3 + subject.OnCompleted (Result.Failure boom) + + // On a failure mapAsync cancels its pending work and publishes the failure synchronously, without waiting for that work + assertFailedWith boom recorder "The aggregation must fail with the exception of the source at once" + Assert.IsEmpty (recorder.Values, "The pending aggregation must not emit a sum") + Assert.IsTrue (sum.Tokens[0].IsCancellationRequested, "The failure must cancel the token of the pending aggregation") + // The sequential worker still waits for the first aggregation, so the chunk flushed by the failure never starts + test <@ sum.Started = [| [| 1; 2 |] |] @> + } + + [] + member _.``asynchronous factories compose through bind synchronously`` (flavour : string) = + let selector = GatedSelector(fun x -> x * 10) + // Released up front, so every factory completes synchronously + for value in 1..3 do + selector.Release value + + use recorder = + Sources.values [| 1; 2; 3 |] + |> Observable.bind (factoryWith flavour selector) + |> Recorder.Attach + + // FromAsync emits a completed factory result inline, and SelectMany completes once the source and all inner sequences have completed + CollectionAssert.AreEqual ([| 10; 20; 30 |], recorder.Values, "Every factory result must arrive in order without waiting") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "The merged sequence must complete synchronously") + CollectionAssert.AreEqual ([| 1; 2; 3 |], selector.Started, "Every element must start one factory") + + [] + member _.``a failing asynchronous factory inside bind fails the merged sequence at once`` (flavour : string) = + let boom : exn = InvalidOperationException "boom" + let selector = GatedSelector(fun x -> if x = 2 then raise boom else x * 10) + + for value in 1..3 do + selector.Release value + + use recorder = + Sources.values [| 1; 2; 3 |] + |> Observable.bind (factoryWith flavour selector) + |> Recorder.Attach + + // FromAsync completes its sequence with the failure of the factory, and SelectMany fails as soon as an inner sequence fails + CollectionAssert.AreEqual ([| 10 |], recorder.Values, "Only the result produced before the failure must arrive") + assertFailedWith boom recorder "The merged sequence must fail with the exception of the factory" + // The failed merge ignores the rest of the source, so no factory starts for the last element + CollectionAssert.AreEqual ([| 1; 2 |], selector.Started, "No factory may start after the failure") + + [] + member _.``disposing a bind pipeline cancels the tokens of its pending asynchronous factories`` (flavour : string) = + use subject = new Subject () + let probe = SubscriptionProbe () + let selector = GatedSelector(fun x -> x * 10) + + use recorder = + subject + |> probe.Watch + |> Observable.bind (factoryWith flavour selector) + |> Recorder.Attach + + subject.OnNext 1 + subject.OnNext 2 + // Both factories start synchronously and wait at their gates, which are never released + CollectionAssert.AreEqual ([| 1; 2 |], selector.Started, "Every element must start one factory") + Assert.IsFalse (selector.Tokens |> Array.exists _.IsCancellationRequested, "No factory token may be cancelled while subscribed") + Assert.IsEmpty (recorder.Values, "No factory result may arrive while the factories wait at their gates") + + recorder.Dispose () + + // SelectMany disposes its inner subscriptions, and FromAsync cancels the token of its factory when it is disposed + Assert.IsTrue (selector.Tokens |> Array.forall _.IsCancellationRequested, "Disposing must cancel the token of every pending factory") + Assert.AreEqual (1, probe.Disposed, "Disposing must unsubscribe the pipeline from the subject") + + [] + member _.``an rxquery feeds the Task and Async terminal functions with the same ordered elements`` () : Task = task { + let ct = testContext.CancellationToken + + let query = rxquery { + for x in Sources.values [| 1..8 |] do + where (x % 2 = 0) + select (x * 10) + } + + let elements = TaskObservable.toArray ct query + // Yield emits synchronously, like every operator of the query, so the Task terminal function completes before it returns + Assert.IsTrue (elements.IsCompletedSuccessfully, "A query over a synchronous source must complete synchronously") + let! taskElements = elements + let! asyncElements = AsyncObservable.toArray query + CollectionAssert.AreEqual ([| 20; 40; 60; 80 |], taskElements, "The query must keep the order of the source") + CollectionAssert.AreEqual (taskElements, asyncElements, "The Async toArray must receive the same elements") + + let! taskLength = TaskObservable.length ct query + let! asyncLength = AsyncObservable.length query + Assert.AreEqual (4, taskLength, "The Task length must count the elements of the query") + Assert.AreEqual (taskLength, asyncLength, "The Async length must agree with the Task length") + } + + [] + member _.``an rxquery whose projection throws faults the Task and Async terminal functions with the same exception`` () : Task = task { + let ct = testContext.CancellationToken + let boom : exn = InvalidOperationException "boom" + + let query = rxquery { + for x in Sources.values [| 1..4 |] do + select (if x = 3 then raise boom else x) + } + + // Select reports the exception of the projection through OnErrorResume, which a terminal function turns into its failure + let! taskError = + Assert.ThrowsAsync( + (fun () -> TaskObservable.toArray ct query :> Task), + "The Task toArray must fail with the exception of the projection" + ) + + let! asyncError = + Assert.ThrowsAsync( + (fun () -> AsyncTest.start ct (AsyncObservable.toArray query) :> Task), + "The Async toArray must fail with the exception of the projection" + ) + + Assert.AreSame (boom, taskError, "The Task flavour must surface the very exception of the projection") + Assert.AreSame (boom, asyncError, "The Async flavour must surface the very exception of the projection") + } + + [] + member _.``chunkByBoundaries driven by a unit subject feeds choose with the sums of the non-empty chunks`` () = + use source = new Subject () + use trigger = new Subject () + let triggerProbe = SubscriptionProbe () + let boundaryError : exn = InvalidOperationException "boundary" + + use recorder = + source + |> Observable.chunkByBoundaries (triggerProbe.Watch trigger) + |> Observable.choose sumOfNonEmpty + |> Recorder.Attach + + source.OnNext 1 + source.OnNext 2 + trigger.OnNext () + // A boundary with nothing buffered emits an empty chunk, which choose drops + trigger.OnNext () + // R3 forwards an error resumed by the boundaries downstream and goes on chunking + trigger.OnErrorResume boundaryError + source.OnNext 4 + source.OnNext 5 + CollectionAssert.AreEqual ([| 3 |], recorder.Values, "Only the boundary that closed buffered elements must produce a sum") + + source.OnCompleted Result.Success + + // Completion of the source flushes the buffer and unsubscribes from the boundaries, which never complete by themselves + CollectionAssert.AreEqual ([| 3; 9 |], recorder.Values, "Completion must flush the buffered elements") + let resumed = Assert.ContainsSingle (recorder.Errors, "Exactly one error must be resumed") + Assert.AreSame (boundaryError, resumed, "The resumed error must be the error of the boundaries") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "The chosen sequence must complete successfully with the source") + Assert.AreEqual (1, triggerProbe.Disposed, "Completion of the source must unsubscribe from the boundaries") + + [] + member _.``a failure of the boundary trigger flushes the buffer and completes the chosen sequence successfully`` () = + use source = new Subject () + use trigger = new Subject () + let sourceProbe = SubscriptionProbe () + let boom : exn = InvalidOperationException "boom" + + use recorder = + sourceProbe.Watch source + |> Observable.chunkByBoundaries trigger + |> Observable.choose sumOfNonEmpty + |> Recorder.Attach + + source.OnNext 1 + source.OnNext 2 + trigger.OnCompleted (Result.Failure boom) + + // R3 ends the chunked sequence on any completion of the boundaries, a failed one included: it flushes and succeeds + CollectionAssert.AreEqual ([| 3 |], recorder.Values, "The completion of the boundaries must flush the buffered elements") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "A failure of the boundaries must complete the chosen sequence successfully") + Assert.IsEmpty (recorder.Errors, "The failure of the boundaries must not be resumed") + Assert.AreEqual (1, sourceProbe.Disposed, "The completion of the boundaries must unsubscribe from the source") + + [] + member _.``a failure of the source flushes the buffer before failing the chosen sequence`` () = + use source = new Subject () + use trigger = new Subject () + let triggerProbe = SubscriptionProbe () + let boom : exn = InvalidOperationException "boom" + + use recorder = + source + |> Observable.chunkByBoundaries (triggerProbe.Watch trigger) + |> Observable.choose sumOfNonEmpty + |> Recorder.Attach + + source.OnNext 1 + source.OnNext 2 + source.OnCompleted (Result.Failure boom) + + // R3 flushes the buffer on any completion of the source before it forwards the result + CollectionAssert.AreEqual ([| 3 |], recorder.Values, "The failure must flush the buffered elements first") + assertFailedWith boom recorder "The chosen sequence must fail with the exception of the source" + Assert.AreEqual (1, triggerProbe.Disposed, "The failure of the source must unsubscribe from the boundaries") + + [] + member _.``ofSeq feeds a pipeline of operators, chunking, mapAsync and a terminal function`` (flavour : string) : Task = task { + let ct = testContext.CancellationToken + let enumerations = ref 0 + + let numbers = seq { + enumerations.Value <- enumerations.Value + 1 + yield! [ 1..10 ] + } + + let sum = GatedSelector Array.sum + // Released up front, so every aggregation finishes as soon as it starts + for chunk in [ [| 10; 30 |]; [| 50; 70 |]; [| 90 |] ] do + sum.Release chunk + + let! sums = + numbers + |> Observable.ofSeq + |> Observable.filter (fun x -> x % 2 = 1) + |> Observable.map (fun x -> x * 10) + |> Observable.chunkBySize 2 + |> Flavour.mapWith flavour ProcessingOptions.Default sum + |> toListWith ct flavour + + // The sequential mapAsync keeps the order of the chunks, the last of which is flushed by the completion of ofSeq + Assert.AreEqual([ 40; 120; 90 ], sums, "The sums of the chunks must arrive in order") + test <@ sum.Started = [| [| 10; 30 |]; [| 50; 70 |]; [| 90 |] |] @> + Assert.AreEqual (1, enumerations.Value, "ofSeq must enumerate the sequence once per subscription") + } + + [] + member _.``cancelling the token of ofSeq during the enumeration completes the whole pipeline successfully`` () : Task = task { + use cancellation = new CancellationTokenSource () + + let pipeline = + Observable.ofSeq ([ 1..10 ], cancellation.Token) + |> Observable.map (fun x -> + // R3 checks the token before every element, so the enumeration ends right after this one + if x = 3 then + cancellation.Cancel () + x + ) + |> Observable.chunkBySize 2 + + let! chunks = TaskObservable.toArray testContext.CancellationToken pipeline + + // A cancelled enumeration completes the sequence successfully, so the partial chunk is flushed as well + test <@ chunks = [| [| 1; 2 |]; [| 3 |] |] @> + } diff --git a/tests/FSharp.Control.R3.Tests/MapAsyncTests.fs b/tests/FSharp.Control.R3.Tests/MapAsyncTests.fs new file mode 100644 index 0000000..98b5e28 --- /dev/null +++ b/tests/FSharp.Control.R3.Tests/MapAsyncTests.fs @@ -0,0 +1,555 @@ +namespace FSharp.Control.R3.Tests + +open System +open System.Threading +open System.Threading.Tasks +open Microsoft.VisualStudio.TestTools.UnitTesting +open R3 +open FSharp.Control.R3 +open FSharp.Control.R3.Tests.TestHelpers + +/// +/// Integration tests of +/// +/// and +/// over : +/// every test runs once per flavour and covers one behaviour of the . +/// +[] +type MapAsyncTests (testContext : TestContext) = + + /// Every await operation, with and without a concurrency limit where R3 uses a different observer for each. + let allConfigurations = [| + AwaitSequential + AwaitDrop + AwaitSwitch + AwaitParallel -1 + AwaitParallel 2 + AwaitSequentialParallel -1 + AwaitSequentialParallel 2 + AwaitThrottleFirstLast + |] + + /// The default options with another await operation. + let optionsWith configuration = { ProcessingOptions.Default with AwaitOperationConfiguration = configuration } + + /// Subscribes and pushes one element with a recording synchronization context installed, then completes the selector + /// from outside that context and reports what the context and the recorder saw. + let mapUnderRecordingContext (cancellationToken : CancellationToken) (flavour : string) (configureAwait : bool) = task { + use subject = new Subject () + let context = RecordingSynchronizationContext () + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + // The selectors hand the gate to R3 without awaiting it themselves, so the await of R3, configured by the option, + // is the only continuation that can capture the context. The gated selector of the helpers would post on its own: + // its task expression captures the context, and so does Async.AwaitTask, even with ConfigureAwait set to false. + // The Async selector is therefore resumed through an awaiter of the gate that does not capture the context + let awaitGate (_ : int) = + Async.FromContinuations (fun (onSuccess, _, _) -> + let awaiter = gate.Task.ConfigureAwait(false).GetAwaiter() + awaiter.OnCompleted (fun () -> onSuccess (awaiter.GetResult ())) + ) + let options = { ProcessingOptions.Parallel with ConfigureAwait = configureAwait } + + // AwaitParallel invokes the selector inside OnNext, so the await of R3 captures the context installed around OnNext. + // The modes with a worker invoke the selector on the worker, where the captured context depends on thread timing + use recorder = + context.Run (fun () -> + let recorder = + subject + |> Flavour.mapAsyncWith flavour options (fun _ _ -> gate.Task) awaitGate + |> Recorder.Attach + subject.OnNext 1 + recorder + ) + + // The unlimited parallel mode of R3 checks its completion without a lock and can lose a completion + // that races with its last selector, so the source completes while the selector is still pending + subject.OnCompleted (Result.Success) + // Completed outside the context, so a continuation can only get back to the context through a post + gate.SetResult 10 + let! completion = recorder.WaitForCompletionAsync cancellationToken + return struct {| + Posts = context.Posts + Values = recorder.Values + Completion = completion + |} + } + + [] + member _.``AwaitSequential runs one selector at a time and emits in input order`` (flavour : string) : Task = task { + let ct = testContext.CancellationToken + use subject = new Subject () + let selector = GatedSelector(fun x -> x * 10) + use recorder = + subject + |> Flavour.mapWith flavour ProcessingOptions.Default selector + |> Recorder.Attach + + subject.OnNext 1 + subject.OnNext 2 + subject.OnNext 3 + // The worker of R3 takes the queued elements one by one, so the second element waits for the first selector + do! selector.WaitForStartedAsync (ct, 1) + CollectionAssert.AreEqual ([| 1 |], selector.Started, "Only the first element may reach the selector while it runs") + + subject.OnCompleted (Result.Success) + Assert.IsTrue (recorder.Completion.IsNone, "The completion must wait for the queued elements") + + // Releasing the later elements first cannot reorder the results: they only start once the earlier ones finish + selector.Release 3 + selector.Release 2 + selector.Release 1 + let! completion = recorder.WaitForCompletionAsync ct + + CollectionAssert.AreEqual ([| 10; 20; 30 |], recorder.Values, "The results must be emitted in input order") + Assert.IsEmpty (recorder.Errors, "No error may be resumed") + Assert.IsTrue (completion.IsSuccess, "The mapped sequence must complete successfully") + CollectionAssert.AreEqual ([| 1; 2; 3 |], selector.Started, "Every element must reach the selector in input order") + Assert.AreEqual (1, selector.MaxInFlight, "The selector invocations must never overlap") + } + + [] + member _.``AwaitDrop discards the elements that arrive while a selector runs`` (flavour : string) : Task = task { + let ct = testContext.CancellationToken + use subject = new Subject () + let selector = GatedSelector(fun x -> x * 10) + use recorder = + subject + |> Flavour.mapWith flavour (optionsWith AwaitDrop) selector + |> Recorder.Attach + + subject.OnNext 1 + do! selector.WaitForStartedAsync (ct, 1) + // R3 marks the operator as running inside OnNext and discards, rather than queues, what arrives meanwhile + subject.OnNext 2 + subject.OnNext 3 + subject.OnCompleted (Result.Success) + Assert.IsTrue (recorder.Completion.IsNone, "The completion must wait for the running selector") + + selector.Release 1 + let! completion = recorder.WaitForCompletionAsync ct + + CollectionAssert.AreEqual ([| 10 |], recorder.Values, "Only the result of the first element may be emitted") + Assert.IsEmpty (recorder.Errors, "No error may be resumed") + Assert.IsTrue (completion.IsSuccess, "The mapped sequence must complete successfully") + CollectionAssert.AreEqual ([| 1 |], selector.Started, "The elements pushed while the selector ran must never reach it") + } + + [] + member _.``AwaitSwitch cancels the token of the running selector and suppresses its result`` (flavour : string) : Task = task { + let ct = testContext.CancellationToken + use subject = new Subject () + let selector = GatedSelector(fun x -> x * 10) + use recorder = + subject + |> Flavour.mapWith flavour (optionsWith AwaitSwitch) selector + |> Recorder.Attach + + // R3 starts a selector inside every OnNext and cancels the token of the one still running + subject.OnNext 1 + subject.OnNext 2 + subject.OnNext 3 + do! selector.WaitForStartedAsync (ct, 3) + + // Checked before the latest selector finishes: R3 disposes the operator once that selector publishes the completion, + // and the disposal cancels its token too + let tokens = selector.Tokens + Assert.HasCount (3, tokens, "Every element must reach the selector") + Assert.IsTrue (tokens[0].IsCancellationRequested, "The second element must cancel the token of the first selector") + Assert.IsTrue (tokens[1].IsCancellationRequested, "The third element must cancel the token of the second selector") + Assert.IsFalse (tokens[2].IsCancellationRequested, "The token of the latest selector must stay active") + Assert.AreEqual (3, selector.MaxInFlight, "A new selector must start without waiting for the superseded ones") + + // The superseded selectors finish after the latest one started, and R3 must drop whatever they return + selector.Release 1 + selector.Release 2 + subject.OnCompleted (Result.Success) + Assert.IsTrue (recorder.Completion.IsNone, "The completion must wait for the latest selector") + + selector.Release 3 + let! completion = recorder.WaitForCompletionAsync ct + + CollectionAssert.AreEqual ([| 30 |], recorder.Values, "Only the result of the latest selector may be emitted") + Assert.IsEmpty (recorder.Errors, "No error may be resumed") + Assert.IsTrue (completion.IsSuccess, "The mapped sequence must complete successfully") + } + + [] + member _.``AwaitParallel without a limit runs every selector at once and emits in completion order`` (flavour : string) : Task = task { + let ct = testContext.CancellationToken + use subject = new Subject () + let selector = GatedSelector(fun x -> x * 10) + use recorder = + subject + |> Flavour.mapWith flavour ProcessingOptions.Parallel selector + |> Recorder.Attach + + subject.OnNext 1 + subject.OnNext 2 + subject.OnNext 3 + do! selector.WaitForStartedAsync (ct, 3) + Assert.AreEqual (3, selector.MaxInFlight, "Every element must reach the selector without waiting for the others") + + selector.Release 2 + do! recorder.WaitForValuesAsync (ct, 1) + selector.Release 3 + do! recorder.WaitForValuesAsync (ct, 2) + CollectionAssert.AreEqual ([| 20; 30 |], recorder.Values, "Every result must be emitted as soon as its selector finishes") + + // The unlimited parallel mode of R3 checks its completion without a lock and can lose a completion + // that races with its last selector, so the source completes while a selector is still pending + subject.OnCompleted (Result.Success) + Assert.IsTrue (recorder.Completion.IsNone, "The completion must wait for the pending selector") + + selector.Release 1 + let! completion = recorder.WaitForCompletionAsync ct + + CollectionAssert.AreEqual ([| 20; 30; 10 |], recorder.Values, "The results must be emitted in completion order") + Assert.IsEmpty (recorder.Errors, "No error may be resumed") + Assert.IsTrue (completion.IsSuccess, "The mapped sequence must complete successfully") + CollectionAssert.AreEqual ([| 1; 2; 3 |], selector.Started, "Every element must reach the selector in input order") + } + + [] + member _.``AwaitParallel with a limit queues the extra elements first in first out`` (flavour : string) : Task = task { + let ct = testContext.CancellationToken + use subject = new Subject () + let selector = GatedSelector(fun x -> x * 10) + use recorder = + subject + |> Flavour.mapWith flavour (optionsWith (AwaitParallel 2)) selector + |> Recorder.Attach + + subject.OnNext 1 + subject.OnNext 2 + subject.OnNext 3 + subject.OnNext 4 + do! selector.WaitForStartedAsync (ct, 2) + CollectionAssert.AreEqual ([| 1; 2 |], selector.Started, "Only two elements may reach the selector while both selectors run") + + // R3 starts the oldest queued element when a selector finishes, right after emitting its result + selector.Release 2 + do! recorder.WaitForValuesAsync (ct, 1) + do! selector.WaitForStartedAsync (ct, 3) + CollectionAssert.AreEqual ([| 1; 2; 3 |], selector.Started, "The oldest queued element must take the free slot") + + selector.Release 3 + do! recorder.WaitForValuesAsync (ct, 2) + do! selector.WaitForStartedAsync (ct, 4) + selector.Release 4 + do! recorder.WaitForValuesAsync (ct, 3) + subject.OnCompleted (Result.Success) + Assert.IsTrue (recorder.Completion.IsNone, "The completion must wait for the pending selector") + + selector.Release 1 + let! completion = recorder.WaitForCompletionAsync ct + + CollectionAssert.AreEqual ([| 20; 30; 40; 10 |], recorder.Values, "The results must be emitted in completion order") + Assert.IsEmpty (recorder.Errors, "No error may be resumed") + Assert.IsTrue (completion.IsSuccess, "The mapped sequence must complete successfully") + CollectionAssert.AreEqual ([| 1; 2; 3; 4 |], selector.Started, "The queued elements must reach the selector in input order") + Assert.AreEqual (2, selector.MaxInFlight, "No more than two selectors may run at the same time") + } + + [] + member _.``AwaitSequentialParallel without a limit runs every selector at once and emits in input order`` (flavour : string) : Task = task { + let ct = testContext.CancellationToken + use subject = new Subject () + let selector = GatedSelector(fun x -> x * 10) + use recorder = + subject + |> Flavour.mapWith flavour (optionsWith (AwaitSequentialParallel -1)) selector + |> Recorder.Attach + + subject.OnNext 1 + subject.OnNext 2 + subject.OnNext 3 + do! selector.WaitForStartedAsync (ct, 3) + Assert.AreEqual (3, selector.MaxInFlight, "Every element must reach the selector without waiting for the others") + + // R3 queues the running selectors in input order, so the results of the later ones wait for the first one + selector.Release 3 + selector.Release 2 + subject.OnCompleted (Result.Success) + Assert.IsTrue (recorder.Completion.IsNone, "The completion must wait for the pending selector") + + selector.Release 1 + let! completion = recorder.WaitForCompletionAsync ct + + CollectionAssert.AreEqual ([| 10; 20; 30 |], recorder.Values, "The results must be emitted in input order") + Assert.IsEmpty (recorder.Errors, "No error may be resumed") + Assert.IsTrue (completion.IsSuccess, "The mapped sequence must complete successfully") + CollectionAssert.AreEqual ([| 1; 2; 3 |], selector.Started, "Every element must reach the selector in input order") + } + + [] + member _.``AwaitSequentialParallel with a limit runs at most that many selectors and emits in input order`` (flavour : string) : Task = task { + let ct = testContext.CancellationToken + use subject = new Subject () + let selector = GatedSelector(fun x -> x * 10) + use recorder = + subject + |> Flavour.mapWith flavour (optionsWith (AwaitSequentialParallel 2)) selector + |> Recorder.Attach + + subject.OnNext 1 + subject.OnNext 2 + subject.OnNext 3 + do! selector.WaitForStartedAsync (ct, 2) + CollectionAssert.AreEqual ([| 1; 2 |], selector.Started, "Only two elements may reach the selector while both selectors run") + + // R3 starts the queued element as soon as a selector finishes, before the result of that selector is emitted + selector.Release 1 + do! selector.WaitForStartedAsync (ct, 3) + do! recorder.WaitForValuesAsync (ct, 1) + CollectionAssert.AreEqual ([| 10 |], recorder.Values, "The result of the first element must be emitted once its selector finishes") + + subject.OnCompleted (Result.Success) + Assert.IsTrue (recorder.Completion.IsNone, "The completion must wait for the pending selectors") + + // The third selector finishes first, but its result must wait for the second one. + // No selector fails here: R3 leaks a slot of this mode when a selector fails + selector.Release 3 + selector.Release 2 + let! completion = recorder.WaitForCompletionAsync ct + + CollectionAssert.AreEqual ([| 10; 20; 30 |], recorder.Values, "The results must be emitted in input order") + Assert.IsEmpty (recorder.Errors, "No error may be resumed") + Assert.IsTrue (completion.IsSuccess, "The mapped sequence must complete successfully") + Assert.AreEqual (2, selector.MaxInFlight, "No more than two selectors may run at the same time") + } + + [] + member _.``AwaitThrottleFirstLast runs the first and the last element of a burst`` (flavour : string) : Task = task { + let ct = testContext.CancellationToken + use subject = new Subject () + let selector = GatedSelector(fun x -> x * 10) + use recorder = + subject + |> Flavour.mapWith flavour (optionsWith AwaitThrottleFirstLast) selector + |> Recorder.Attach + + subject.OnNext 1 + // The worker of R3 must take the first element before the burst arrives, or the burst would replace it + do! selector.WaitForStartedAsync (ct, 1) + // While the selector runs, R3 buffers a single element and drops the oldest one when another arrives + subject.OnNext 2 + subject.OnNext 3 + subject.OnNext 4 + subject.OnNext 5 + subject.OnCompleted (Result.Success) + Assert.IsTrue (recorder.Completion.IsNone, "The completion must wait for the running selector and the buffered element") + + selector.Release 1 + do! selector.WaitForStartedAsync (ct, 2) + CollectionAssert.AreEqual ([| 1; 5 |], selector.Started, "Only the last element of the burst may follow the first one") + + selector.Release 5 + let! completion = recorder.WaitForCompletionAsync ct + + CollectionAssert.AreEqual ([| 10; 50 |], recorder.Values, "The results of the first and the last element must be emitted") + Assert.IsEmpty (recorder.Errors, "No error may be resumed") + Assert.IsTrue (completion.IsSuccess, "The mapped sequence must complete successfully") + } + + [] + member _.``CancelOnCompleted completes at once, cancels the running selector token and drops its pending result`` (flavour : string) : Task = task { + let ct = testContext.CancellationToken + for configuration in allConfigurations do + use subject = new Subject () + let selector = GatedSelector(fun x -> x * 10) + let options = { + ProcessingOptions.Default with + AwaitOperationConfiguration = configuration + CancelOnCompleted = true + } + use recorder = + subject + |> Flavour.mapWith flavour options selector + |> Recorder.Attach + + subject.OnNext 1 + do! selector.WaitForStartedAsync (ct, 1) + // R3 cancels its token and publishes the completion inside OnCompleted of the source + subject.OnCompleted (Result.Success) + + Assert.IsTrue (recorder.IsCompletedSuccessfully, $"%A{configuration}: the mapped sequence must complete with the source") + Assert.IsTrue (selector.Tokens[0].IsCancellationRequested, $"%A{configuration}: the token of the running selector must be cancelled") + // The recorder has completed, so the result the selector may still return can never be emitted + Assert.IsEmpty (recorder.Values, $"%A{configuration}: the pending result must be dropped") + Assert.IsEmpty (recorder.Errors, $"%A{configuration}: no error may be resumed") + } + + [] + member _.``A source failure completes at once and cancels the running selector token even without CancelOnCompleted`` (flavour : string) : Task = + task { + let ct = testContext.CancellationToken + let boom : exn = InvalidOperationException "boom" + for configuration in allConfigurations do + use subject = new Subject () + let selector = GatedSelector(fun x -> x * 10) + use recorder = + subject + |> Flavour.mapWith flavour (optionsWith configuration) selector + |> Recorder.Attach + + subject.OnNext 1 + do! selector.WaitForStartedAsync (ct, 1) + // R3 handles a failure of the source like CancelOnCompleted, whatever the option says + subject.OnCompleted (Result.Failure boom) + + assertFailedWith boom recorder $"%A{configuration}: the mapped sequence must fail at once with the failure of the source" + Assert.IsTrue (selector.Tokens[0].IsCancellationRequested, $"%A{configuration}: the token of the running selector must be cancelled") + Assert.IsEmpty (recorder.Values, $"%A{configuration}: the pending result must be dropped") + Assert.IsEmpty (recorder.Errors, $"%A{configuration}: no error may be resumed") + } + + [] + member _.``An exception of the selector is resumed and the later elements are still mapped`` (flavour : string) : Task = task { + let ct = testContext.CancellationToken + let boom : exn = InvalidOperationException "boom" + let selector = GatedSelector(fun x -> if x = 2 then raise boom else x * 10) + // Released up front, so every invocation finishes as soon as it starts + selector.Release 1 + selector.Release 2 + selector.Release 3 + use recorder = + Sources.values [| 1; 2; 3 |] + |> Flavour.mapWith flavour ProcessingOptions.Default selector + |> Recorder.Attach + + let! completion = recorder.WaitForCompletionAsync ct + + CollectionAssert.AreEqual ([| 10; 30 |], recorder.Values, "The elements around the failing one must still be mapped") + let error = + Assert.ContainsSingle (recorder.Errors, "The exception of the selector must be resumed once") + // R3 awaits the task of the selector, which rethrows the original exception in both flavours: + // the Async flavour raises it inside the computation, which faults the task with that same exception + Assert.AreSame (boom, error, "The resumed error must be the exception of the selector") + Assert.IsTrue (completion.IsSuccess, "An exception of the selector must not fail the mapped sequence") + } + + [] + member _.``An error resumed by the source is forwarded and the sequence continues`` (flavour : string) : Task = task { + let ct = testContext.CancellationToken + let boom : exn = InvalidOperationException "boom" + let selector = GatedSelector(fun x -> x * 10) + selector.Release 1 + selector.Release 2 + use recorder = + Sources.resumingError boom + |> Flavour.mapWith flavour ProcessingOptions.Default selector + |> Recorder.Attach + + let! completion = recorder.WaitForCompletionAsync ct + + CollectionAssert.AreEqual ([| 10; 20 |], recorder.Values, "The elements around the error must still be mapped") + let error = Assert.ContainsSingle (recorder.Errors, "The error of the source must be forwarded once") + Assert.AreSame (boom, error, "The forwarded error must be the error of the source") + Assert.IsTrue (completion.IsSuccess, "An error resumed by the source must not fail the mapped sequence") + } + + [] + member _.``An empty source completes successfully without invoking the selector`` (flavour : string) : Task = task { + let ct = testContext.CancellationToken + for configuration in allConfigurations do + let selector = GatedSelector(fun x -> x * 10) + use recorder = + Sources.values [||] + |> Flavour.mapWith flavour (optionsWith configuration) selector + |> Recorder.Attach + + // The modes with a worker publish the completion from the worker, which may run later + let! completion = recorder.WaitForCompletionAsync ct + + Assert.IsTrue (completion.IsSuccess, $"%A{configuration}: the mapped sequence must complete successfully") + Assert.IsEmpty (recorder.Values, $"%A{configuration}: no value may be emitted") + Assert.IsEmpty (recorder.Errors, $"%A{configuration}: no error may be resumed") + Assert.IsEmpty (selector.Started, $"%A{configuration}: the selector must never be invoked") + } + + [] + member _.``A concurrency limit of 0 or below -1 is rejected when mapAsync is called, before any subscription`` (flavour : string) = + let selector = GatedSelector(fun x -> x * 10) + + for limit in [| 0; -2 |] do + for configuration in [| AwaitParallel limit; AwaitSequentialParallel limit |] do + // R3 itself validates the limit only when the mapped sequence is subscribed, with an ArgumentException. + // The helper never subscribes the mapped sequence, so only the eager validation of the library can throw here + assertArgumentRejected + (Flavour.mapWith flavour (optionsWith configuration) selector) + "options" + limit + $"mapAsync must reject %A{configuration} as soon as it is called" + + [] + member _.``AwaitParallel 1 is accepted and runs one selector at a time`` (flavour : string) : Task = task { + let ct = testContext.CancellationToken + use subject = new Subject () + let selector = GatedSelector(fun x -> x * 10) + // Like R3, the eager validation of the library rejects only 0 and limits below -1, + // although the message of R3 asks for a limit greater than 1 + use recorder = + subject + |> Flavour.mapWith flavour (optionsWith (AwaitParallel 1)) selector + |> Recorder.Attach + + subject.OnNext 1 + subject.OnNext 2 + do! selector.WaitForStartedAsync (ct, 1) + CollectionAssert.AreEqual ([| 1 |], selector.Started, "The second element must be queued while the first selector runs") + + subject.OnCompleted (Result.Success) + Assert.IsTrue (recorder.Completion.IsNone, "The completion must wait for the running and the queued element") + + selector.Release 2 + selector.Release 1 + let! completion = recorder.WaitForCompletionAsync ct + + CollectionAssert.AreEqual ([| 10; 20 |], recorder.Values, "The results must be emitted in input order") + Assert.IsEmpty (recorder.Errors, "No error may be resumed") + Assert.IsTrue (completion.IsSuccess, "The mapped sequence must complete successfully") + Assert.AreEqual (1, selector.MaxInFlight, "The selector invocations must never overlap") + } + + [] + member _.``Disposing the subscription cancels the running selector token and unsubscribes from the source`` (flavour : string) : Task = task { + let ct = testContext.CancellationToken + for configuration in allConfigurations do + use subject = new Subject () + let probe = SubscriptionProbe () + let selector = GatedSelector(fun x -> x * 10) + use recorder = + subject + |> probe.Watch + |> Flavour.mapWith flavour (optionsWith configuration) selector + |> Recorder.Attach + + subject.OnNext 1 + do! selector.WaitForStartedAsync (ct, 1) + // Disposing the recorder disposes the observer of R3, which cancels its token and unsubscribes from the source. + // The disposed recorder ignores every later notification, so only the token and the probe can show the teardown + recorder.Dispose () + + Assert.IsTrue (selector.Tokens[0].IsCancellationRequested, $"%A{configuration}: the token of the running selector must be cancelled") + // The subject never completes by itself, so the disposal seen by the probe can only come from an unsubscription + Assert.AreEqual (1, probe.Disposed, $"%A{configuration}: the subscription to the source must be disposed") + } + + [] + member _.``ConfigureAwait true resumes the selector continuation on the synchronization context of OnNext`` (flavour : string) : Task = task { + let! outcome = mapUnderRecordingContext testContext.CancellationToken flavour true + + Assert.IsTrue (outcome.Posts >= 1, "The continuation of the selector must be posted to the context captured inside OnNext") + CollectionAssert.AreEqual ([| 10 |], outcome.Values, "The result of the selector must be emitted") + Assert.IsTrue (outcome.Completion.IsSuccess, "The mapped sequence must complete successfully") + } + + [] + member _.``ConfigureAwait false resumes the selector continuation without the synchronization context of OnNext`` (flavour : string) : Task = task { + let! outcome = mapUnderRecordingContext testContext.CancellationToken flavour false + + Assert.AreEqual (0, outcome.Posts, "No continuation may be posted to the context captured inside OnNext") + CollectionAssert.AreEqual ([| 10 |], outcome.Values, "The result of the selector must be emitted") + Assert.IsTrue (outcome.Completion.IsSuccess, "The mapped sequence must complete successfully") + } diff --git a/tests/FSharp.Control.R3.Tests/ObservableTests.fs b/tests/FSharp.Control.R3.Tests/ObservableTests.fs index 2a6a2b6..9b2cf97 100644 --- a/tests/FSharp.Control.R3.Tests/ObservableTests.fs +++ b/tests/FSharp.Control.R3.Tests/ObservableTests.fs @@ -1,65 +1,979 @@ namespace FSharp.Control.R3.Tests open System -open System.Threading.Tasks -open FSharp.Control.R3.Async +open System.Threading open Microsoft.VisualStudio.TestTools.UnitTesting -open Swensen.Unquote +open R3 +open FSharp.Control.R3 +open FSharp.Control.R3.Tests.TestHelpers +// Every source in this class delivers synchronously on the calling thread: subjects, ToObservable, Return, Range and +// Empty push inside OnNext or Subscribe, and the operators under test forward inline. So every test asserts right after +// the call that triggers a notification, without waiting, and the class needs no TestContext token. + +/// +/// Operators of the module under the names of their data rows, each one +/// configured so that it passes the elements of an int source through unchanged. +/// +module private PassThrough = + + // The tests of completion, failure and OnErrorResume forwarding apply the same expectations to every operator. This + // table is the only list of them: it feeds both the data rows and the lookup by name, so an operator added here is + // covered by every one of those tests + let all : struct (string * (Observable -> Observable)) array = [| + struct ("asObservable", Observable.asObservable) + struct ("bind", Observable.bind Observable.singleton) + struct ("cast", Observable.cast) + // The handler type matches none of the failures in these tests, so catch forwards them + struct ("catch", Observable.catch (fun (_ : ArgumentException) -> Observable.empty ())) + struct ("choose", Observable.choose ValueSome) + struct ("ObservableOption.choose", ObservableOption.choose Some) + // An empty first sequence makes concat emit the source unchanged + struct ("concat", Observable.concat (Observable.empty ())) + struct ("distinct", Observable.distinct) + struct ("filter", Observable.filter (fun _ -> true)) + struct ("map", Observable.map id) + struct ("mapi", Observable.mapi (fun _ value -> value)) + // An empty second source completes at once, so merge completes together with the source + struct ("merge", fun source -> Observable.merge (source, Observable.empty ())) + struct ("ofType", Observable.ofType) + struct ("skip", Observable.skip 0) + struct ("take", Observable.take Int32.MaxValue) + struct ("where", Observable.where (fun _ -> true)) + |] + + /// Applies the operator of the data row with the name to the source. + let apply (name : string) (source : Observable) : Observable = + let struct (_, operator) = + all + |> Array.find (fun struct (candidate, _) -> String.Equals (candidate, name, StringComparison.Ordinal)) + + operator source + +/// +/// Integration tests of the functions of the module and of the +/// factories against R3. +/// +/// The chunking functions are covered by , and the +/// query expressions by +/// . +/// +/// [] type ObservableTests () = + /// The names of the operators that the forwarding tests check, as MSTest dynamic data. + static member PassThroughOperators : obj array seq = + PassThrough.all + |> Seq.map (fun struct (name, _) -> name) + |> dataRows + + [] + member _.``operators complete successfully without values over an empty source`` (operator : string) = + use recorder = + (Observable.empty () : Observable) + |> PassThrough.apply operator + |> Recorder.Attach + + Assert.IsEmpty (recorder.Values, $"%s{operator} must not emit over an empty source") + Assert.IsEmpty (recorder.Errors, $"%s{operator} must not report an error over an empty source") + Assert.IsTrue (recorder.IsCompletedSuccessfully, $"%s{operator} must complete successfully when the source completes empty") + + [] + member _.``operators forward the terminal failure of the source`` (operator : string) = + let boom : exn = InvalidOperationException "boom" + + use recorder = + Sources.failingAfter [| 1; 2 |] boom + |> PassThrough.apply operator + |> Recorder.Attach + + CollectionAssert.AreEqual ([| 1; 2 |], recorder.Values, $"%s{operator} must emit the elements that precede the failure") + Assert.IsEmpty (recorder.Errors, $"%s{operator} must not turn the failure into an error reported through OnErrorResume") + assertFailedWith boom recorder $"%s{operator} must complete with the failure of the source" + + [] + member _.``operators forward the errors that the source reports through OnErrorResume`` (operator : string) = + let boom : exn = InvalidOperationException "boom" + + use recorder = + Sources.resumingError boom + |> PassThrough.apply operator + |> Recorder.Attach + + CollectionAssert.AreEqual ([| 1; 2 |], recorder.Values, $"%s{operator} must keep emitting after an error reported through OnErrorResume") + CollectionAssert.AreEqual ([| boom |], recorder.Errors, $"%s{operator} must forward the error unchanged") + Assert.IsTrue (recorder.IsCompletedSuccessfully, $"%s{operator} must not turn an error reported through OnErrorResume into a failure") + [] - member _.``Test length`` () : Task = + member _.``asObservable hides the subject and forwards every notification`` () = + let boom : exn = InvalidOperationException "boom" + let failure : exn = TimeoutException "failure" + use subject = new Subject () + let hidden = subject |> Observable.asObservable + use recorder = Recorder.Attach hidden - async { - use r3Bus = new R3.Subject () + subject.OnNext 1 + subject.OnErrorResume boom + subject.OnCompleted (Result.Failure failure) - r3Bus.OnNext 1 + // R3 AsObservable wraps the source in another observable, so a consumer cannot cast it back and push values + Assert.IsNotInstanceOfType>(hidden, "asObservable must not expose the subject") + CollectionAssert.AreEqual ([| 1 |], recorder.Values, "asObservable must forward the elements") + CollectionAssert.AreEqual ([| boom |], recorder.Errors, "asObservable must forward the errors reported through OnErrorResume") + assertFailedWith failure recorder "asObservable must forward the failure" + + [] + member _.``bind flattens synchronous inner sequences in source order`` () = + use recorder = + Sources.values [| 1; 2; 3 |] + |> Observable.bind (fun x -> Observable.Range (x * 10, 2)) + |> Recorder.Attach + + // Every Range completes inside its own Subscribe, before SelectMany receives the next element of the source + CollectionAssert.AreEqual ([| 10; 11; 20; 21; 30; 31 |], recorder.Values, "bind must emit the inner elements in source order") + Assert.IsEmpty (recorder.Errors, "bind must not report an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "bind must complete when the source and every inner sequence have completed") + + [] + member _.``bind interleaves hot inner sequences and completes only after the source and every inner sequence complete`` () = + use source = new Subject () + use first = new Subject () + use second = new Subject () + + use recorder = + source + |> Observable.bind (fun x -> + if x = 1 then + first :> Observable + else + second :> Observable + ) + |> Recorder.Attach + + source.OnNext 1 + source.OnNext 2 + second.OnNext "b1" + first.OnNext "a1" + source.OnCompleted (Result.Success) + first.OnCompleted (Result.Success) + // R3 SelectMany completes only once the source is stopped and its last running inner sequence completes + Assert.IsTrue (recorder.Completion.IsNone, "bind must wait for the inner sequence that is still running") + + second.OnNext "b2" + second.OnCompleted (Result.Success) + + CollectionAssert.AreEqual ([| "b1"; "a1"; "b2" |], recorder.Values, "bind must emit the inner elements in arrival order") + Assert.IsEmpty (recorder.Errors, "bind must not report an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "bind must complete when the last inner sequence completes") + + [] + member _.``bind fails as soon as an inner sequence fails and unsubscribes from the source and the other inner sequences`` () = + let boom : exn = InvalidOperationException "boom" + use source = new Subject () + use first = new Subject () + use second = new Subject () + let sourceProbe = SubscriptionProbe () + let secondProbe = SubscriptionProbe () + + use recorder = + source + |> sourceProbe.Watch + |> Observable.bind (fun x -> + if x = 1 then + first :> Observable + else + secondProbe.Watch second + ) + |> Recorder.Attach + + source.OnNext 1 + source.OnNext 2 + first.OnNext "a1" + // The source is still open here: R3 SelectMany drops an inner failure that arrives after the source has completed + first.OnCompleted (Result.Failure boom) + + CollectionAssert.AreEqual ([| "a1" |], recorder.Values, "bind must forward only the inner element that arrived before the failure") + Assert.IsEmpty (recorder.Errors, "bind must not turn the failure into an error reported through OnErrorResume") + assertFailedWith boom recorder "bind must fail with the failure of the inner sequence" + // The completed Recorder ignores every later notification, so it cannot show that bind stopped forwarding; the source + // and the second inner sequence are open subjects, so these disposals prove the unsubscription + Assert.AreEqual (1, sourceProbe.Disposed, "bind must unsubscribe from the source when an inner sequence fails") + Assert.AreEqual (1, secondProbe.Disposed, "bind must unsubscribe from the other inner sequences when one fails") + + [] + member _.``disposing a bind subscription unsubscribes from the source and every inner sequence`` () = + use source = new Subject () + use inner = new Subject () + let sourceProbe = SubscriptionProbe () + let innerProbe = SubscriptionProbe () + + use recorder = + source + |> sourceProbe.Watch + |> Observable.bind (fun _ -> innerProbe.Watch inner) + |> Recorder.Attach + + source.OnNext 1 + inner.OnNext "a" + CollectionAssert.AreEqual ([| "a" |], recorder.Values, "bind must forward the inner element while subscribed") + recorder.Dispose () + source.OnNext 2 + + // The disposed Recorder ignores every notification, so only the probes can show the unsubscription; both subjects + // stay open, so these disposals prove it + Assert.AreEqual (1, sourceProbe.Disposed, "Disposing the subscription must unsubscribe from the source") + Assert.AreEqual (1, innerProbe.Subscribed, "A source element pushed after the disposal must not subscribe to an inner sequence") + Assert.AreEqual (1, innerProbe.Disposed, "Disposing the subscription must unsubscribe from the running inner sequence") + + [] + member _.``cast converts matching elements and reports the others as InvalidCastException without stopping`` () = + use recorder = + Sources.values [| "a" :> obj; 1 :> obj; "b" :> obj |] + |> Observable.cast + |> Recorder.Attach + + CollectionAssert.AreEqual ([| "a"; "b" |], recorder.Values, "cast must emit the elements of the target type") + // R3 Cast throws inside OnNextCore, and Observer.OnNext reports the exception through OnErrorResume + let error = + Assert.ContainsSingle (recorder.Errors, "cast must report exactly the one element that cannot be cast") + Assert.IsInstanceOfType(error, "cast must report an InvalidCastException") + |> ignore + Assert.IsTrue (recorder.IsCompletedSuccessfully, "cast must continue after an invalid element and complete with the source") + + [] + member _.``catch switches to the handler sequence on a matching failure and keeps the earlier values`` () = + let boom : exn = InvalidOperationException "boom" + let handled = ResizeArray() + + use recorder = + Sources.failingAfter [| 1; 2 |] boom + |> Observable.catch (fun (error : InvalidOperationException) -> + handled.Add error + Sources.values [| 9 |] + ) + |> Recorder.Attach + + CollectionAssert.AreEqual ([| 1; 2; 9 |], recorder.Values, "catch must keep the elements before the failure and append the handler sequence") + Assert.IsEmpty (recorder.Errors, "catch must not report the handled failure as an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "catch must complete with the handler sequence") + CollectionAssert.AreEqual ([| boom |], handled.ToArray (), "The handler must be called once with the failure of the source") + + [] + member _.``catch forwards a failure whose type does not match the handler`` () = + let boom : exn = InvalidOperationException "boom" + let handlerCalls = ref 0 + + use recorder = + Sources.failingAfter [| 1 |] boom + |> Observable.catch (fun (_ : ArgumentException) -> + handlerCalls.Value <- handlerCalls.Value + 1 + Sources.values [| 9 |] + ) + |> Recorder.Attach + + CollectionAssert.AreEqual ([| 1 |], recorder.Values, "catch must keep the elements before the failure") + Assert.IsEmpty (recorder.Errors, "catch must not turn the failure into an error reported through OnErrorResume") + assertFailedWith boom recorder "catch must forward a failure of another type unchanged" + Assert.AreEqual (0, handlerCalls.Value, "The handler must not be called for a failure of another type") + + [] + member _.``catch with an unannotated handler catches a failure of any type`` () = + let failure : exn = TimeoutException "failure" + // box returns objnull, the nullable obj of F# nullness checking + let handled = ResizeArray() + + use recorder = + Sources.failingAfter [| 1 |] failure + // Without an annotation F# infers the handler argument as obj, and R3 Catch puts no Exception constraint on it, + // so its type test accepts every failure + |> Observable.catch (fun error -> + handled.Add (box error) + Sources.values [| 0 |] + ) + |> Recorder.Attach + + CollectionAssert.AreEqual ([| 1; 0 |], recorder.Values, "catch must switch to the handler sequence") + Assert.IsEmpty (recorder.Errors, "catch must not report the handled failure as an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "catch must complete with the handler sequence") + CollectionAssert.AreEqual ([| box failure |], handled.ToArray (), "The handler must be called once with the failure of the source") + + [] + member _.``catch does not intercept errors reported through OnErrorResume`` () = + let boom : exn = InvalidOperationException "boom" + let handlerCalls = ref 0 - let lengthObs = Observable.length r3Bus + use recorder = + Sources.resumingError boom + |> Observable.catch (fun (_ : exn) -> + handlerCalls.Value <- handlerCalls.Value + 1 + Sources.values [| 99 |] + ) + |> Recorder.Attach + + // R3 Catch forwards OnErrorResume unchanged and handles only a failed completion + CollectionAssert.AreEqual ([| 1; 2 |], recorder.Values, "catch must keep emitting the source after an error reported through OnErrorResume") + CollectionAssert.AreEqual ([| boom |], recorder.Errors, "catch must forward the error unchanged") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "catch must complete with the source") + Assert.AreEqual (0, handlerCalls.Value, "The handler must not be called for an error reported through OnErrorResume") + + [] + member _.``catch fails with the failure of the handler sequence`` () = + let boom : exn = InvalidOperationException "boom" + let handlerFailure : exn = InvalidOperationException "handler failure" + let handlerCalls = ref 0 + + use recorder = + Sources.failingAfter [| 1 |] boom + |> Observable.catch (fun (_ : InvalidOperationException) -> + handlerCalls.Value <- handlerCalls.Value + 1 + Sources.failingAfter [| 2 |] handlerFailure + ) + |> Recorder.Attach + + // R3 forwards the completion of the handler sequence as it is, so a failure of a matching type is not handled again + CollectionAssert.AreEqual ([| 1; 2 |], recorder.Values, "catch must emit the source and then the handler sequence") + Assert.IsEmpty (recorder.Errors, "catch must not report either failure as an error") + assertFailedWith handlerFailure recorder "catch must fail with the failure of the handler sequence" + Assert.AreEqual (1, handlerCalls.Value, "The handler must be called only for the failure of the source") + + [] + member _.``concat subscribes to the second sequence only after the first completes`` () = + use first = new Subject () + use second = new Subject () + let secondProbe = SubscriptionProbe () + + use recorder = + Observable.concat first (secondProbe.Watch second) + |> Recorder.Attach + + // Nobody listens to the second subject yet, and a subject drops the elements pushed while nobody is subscribed + second.OnNext 0 + first.OnNext 1 + Assert.AreEqual (0, secondProbe.Subscribed, "concat must not subscribe to the second sequence while the first is running") + + first.OnCompleted (Result.Success) + Assert.AreEqual (1, secondProbe.Subscribed, "concat must subscribe to the second sequence when the first completes") + Assert.IsTrue (recorder.Completion.IsNone, "concat must not complete before the second sequence completes") + + second.OnNext 2 + second.OnCompleted (Result.Success) + + CollectionAssert.AreEqual ([| 1; 2 |], recorder.Values, "concat must emit the first sequence and then the second") + Assert.IsEmpty (recorder.Errors, "concat must not report an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "concat must complete when the second sequence completes") + + [] + member _.``concat in a pipeline emits its argument first, like R3 Concat called on the argument`` () = + let earlier = Sources.values [| 1; 2 |] + let later = Sources.values [| 3; 4 |] + + use recorder = later |> Observable.concat earlier |> Recorder.Attach + use reference = earlier |> _.Concat(later) |> Recorder.Attach + + CollectionAssert.AreEqual ([| 1; 2; 3; 4 |], recorder.Values, "a |> concat b must emit b before a") + CollectionAssert.AreEqual (reference.Values, recorder.Values, "a |> concat b must emit what b.Concat(a) emits") + Assert.IsEmpty (recorder.Errors, "concat must not report an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "concat must complete when both sequences have completed") + + [] + member _.``concat stops at a failure of the first sequence without subscribing to the second`` () = + let boom : exn = InvalidOperationException "boom" + use second = new Subject () + let secondProbe = SubscriptionProbe () + + use recorder = + Observable.concat (Sources.failingAfter [| 1 |] boom) (secondProbe.Watch second) + |> Recorder.Attach + + CollectionAssert.AreEqual ([| 1 |], recorder.Values, "concat must emit the elements before the failure") + Assert.IsEmpty (recorder.Errors, "concat must not turn the failure into an error reported through OnErrorResume") + assertFailedWith boom recorder "concat must fail with the failure of the first sequence" + Assert.AreEqual (0, secondProbe.Subscribed, "concat must not subscribe to the second sequence after a failure") + + [] + member _.``disposing a concat subscription unsubscribes from the first sequence and never subscribes to the second`` () = + use first = new Subject () + use second = new Subject () + let firstProbe = SubscriptionProbe () + let secondProbe = SubscriptionProbe () + + use recorder = + Observable.concat (firstProbe.Watch first) (secondProbe.Watch second) + |> Recorder.Attach + + first.OnNext 1 + CollectionAssert.AreEqual ([| 1 |], recorder.Values, "concat must forward the first sequence while subscribed") + recorder.Dispose () + // The disposed Recorder ignores every notification, so only the probes can show the unsubscription. R3 also + // disposes the probe's Do wrapper when its source completes, so the disposal is checked while the first subject + // is still open + Assert.AreEqual (1, firstProbe.Disposed, "Disposing the subscription must unsubscribe from the first sequence") + + first.OnCompleted (Result.Success) + Assert.AreEqual (0, secondProbe.Subscribed, "The first sequence completing after the disposal must not subscribe to the second") + + [] + member _.``distinct drops repeated elements and keeps the first occurrences in order`` () = + use recorder = + Sources.values [| 1; 2; 1; 3; 2 |] + |> Observable.distinct + |> Recorder.Attach + + CollectionAssert.AreEqual ([| 1; 2; 3 |], recorder.Values, "distinct must emit only the first occurrence of every element") + Assert.IsEmpty (recorder.Errors, "distinct must not report an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "distinct must complete with the source") + + [] + member _.``empty completes successfully on subscription without emitting`` () = + use recorder = Recorder.Attach (Observable.empty () : Observable) + + // R3 Empty completes inside Subscribe, so the outcome is known as soon as Attach returns + Assert.IsEmpty (recorder.Values, "empty must not emit") + Assert.IsEmpty (recorder.Errors, "empty must not report an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "empty must complete successfully on subscription") + + [] + member _.``filter keeps the matching elements that a hot subject emits after subscription`` () = + use subject = new Subject () + let evens = subject |> Observable.filter (fun x -> x % 2 = 0) + // A subject drops the elements pushed while nobody is subscribed + subject.OnNext 2 + use recorder = Recorder.Attach evens + + subject.OnNext 3 + subject.OnNext 4 + subject.OnNext 5 + + CollectionAssert.AreEqual ([| 4 |], recorder.Values, "filter must emit only the matching elements pushed after subscription") + Assert.IsTrue (recorder.Completion.IsNone, "filter must stay open while the subject is open") + subject.OnCompleted (Result.Success) + Assert.IsEmpty (recorder.Errors, "filter must not report an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "filter must complete with the subject") + + [] + member _.``filter reports a predicate exception through OnErrorResume and keeps filtering`` () = + let boom : exn = InvalidOperationException "boom" + + use recorder = + Sources.values [| 1; 2; 3; 4; 5 |] + |> Observable.filter (fun x -> if x = 3 then raise boom else x % 2 = 0) + |> Recorder.Attach + + // Observer.OnNext reports an exception of the predicate through OnErrorResume instead of failing the sequence + CollectionAssert.AreEqual ([| 2; 4 |], recorder.Values, "filter must keep filtering after the predicate throws") + CollectionAssert.AreEqual ([| boom |], recorder.Errors, "filter must report the exception of the predicate") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "filter must complete with the source") + + [] + member _.``where and filter emit the same elements as R3 Where`` () = + let source = Sources.values [| 1; 2; 3; 4; 5; 6 |] + + use whereRecorder = + source + |> Observable.where (fun x -> x % 2 = 0) + |> Recorder.Attach + use filterRecorder = + source + |> Observable.filter (fun x -> x % 2 = 0) + |> Recorder.Attach + use reference = source |> _.Where(fun x -> x % 2 = 0) |> Recorder.Attach + + CollectionAssert.AreEqual ([| 2; 4; 6 |], reference.Values, "R3 Where must keep the even elements") + CollectionAssert.AreEqual (reference.Values, whereRecorder.Values, "where must emit what R3 Where emits") + CollectionAssert.AreEqual (reference.Values, filterRecorder.Values, "filter must emit what R3 Where emits") + Assert.IsEmpty (whereRecorder.Errors, "where must not report an error") + Assert.IsEmpty (filterRecorder.Errors, "filter must not report an error") + Assert.IsTrue (whereRecorder.IsCompletedSuccessfully, "where must complete with the source") + Assert.IsTrue (filterRecorder.IsCompletedSuccessfully, "filter must complete with the source") + + [] + member _.``map projects every element in order`` () = + use recorder = + Sources.values [| 1; 2; 3 |] + |> Observable.map (fun x -> x * 10) + |> Recorder.Attach + + CollectionAssert.AreEqual ([| 10; 20; 30 |], recorder.Values, "map must project every element in order") + Assert.IsEmpty (recorder.Errors, "map must not report an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "map must complete with the source") + + [] + member _.``map reports a selector exception through OnErrorResume and keeps projecting`` () = + let boom : exn = InvalidOperationException "boom" + + use recorder = + Sources.values [| 1; 2; 3 |] + |> Observable.map (fun x -> if x = 2 then raise boom else x * 10) + |> Recorder.Attach + + // Observer.OnNext reports an exception of the selector through OnErrorResume instead of failing the sequence + CollectionAssert.AreEqual ([| 10; 30 |], recorder.Values, "map must keep projecting after the selector throws") + CollectionAssert.AreEqual ([| boom |], recorder.Errors, "map must report the exception of the selector") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "map must complete with the source") + + [] + member _.``mapi passes the zero-based index first and the element second, like R3 indexed Select`` () = + let source = Sources.values [| "a"; "b"; "c" |] - r3Bus.OnNext 2 - r3Bus.OnNext 3 - r3Bus.OnCompleted (R3.Result.Success) + use recorder = + source + |> Observable.mapi (fun index value -> $"%d{index}:%s{value}") + |> Recorder.Attach - let! res = lengthObs + use reference = + source + |> _.Select(fun value index -> $"%d{index}:%s{value}") + |> Recorder.Attach - Assert.AreEqual(0, res) + CollectionAssert.AreEqual ([| "0:a"; "1:b"; "2:c" |], recorder.Values, "mapi must pass the zero-based index and then the element") + CollectionAssert.AreEqual (reference.Values, recorder.Values, "mapi must emit what R3 indexed Select emits") + Assert.IsEmpty (recorder.Errors, "mapi must not report an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "mapi must complete with the source") + [] + member _.``mapi counts the index separately for every subscription`` () = + use subject = new Subject () + let indexed = + subject + |> Observable.mapi (fun index value -> $"%d{index}:%s{value}") + + use first = Recorder.Attach indexed + subject.OnNext "a" + use second = Recorder.Attach indexed + subject.OnNext "b" + subject.OnNext "c" + subject.OnCompleted (Result.Success) + + // R3 indexed Select keeps the index in its observer, so every subscription counts from zero + CollectionAssert.AreEqual ([| "0:a"; "1:b"; "2:c" |], first.Values, "The first subscription must count every element it received") + CollectionAssert.AreEqual ([| "0:b"; "1:c" |], second.Values, "A later subscription must count from zero") + Assert.IsEmpty (first.Errors, "mapi must not report an error to the first subscription") + Assert.IsEmpty (second.Errors, "mapi must not report an error to the later subscription") + Assert.IsTrue (first.IsCompletedSuccessfully, "mapi must complete the first subscription with the subject") + Assert.IsTrue (second.IsCompletedSuccessfully, "mapi must complete the later subscription with the subject") + + [] + member _.``merge forwards both sources in emission order and completes after both complete`` () = + use left = new Subject () + use right = new Subject () + use recorder = Observable.merge (left, right) |> Recorder.Attach + + left.OnNext 1 + right.OnNext 2 + left.OnNext 3 + left.OnCompleted (Result.Success) + Assert.IsTrue (recorder.Completion.IsNone, "merge must stay open while the other source is running") + + right.OnNext 4 + right.OnCompleted (Result.Success) + + CollectionAssert.AreEqual ([| 1; 2; 3; 4 |], recorder.Values, "merge must emit the elements of both sources in emission order") + Assert.IsEmpty (recorder.Errors, "merge must not report an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "merge must complete when both sources have completed") + + [] + member _.``merge fails as soon as either source fails and unsubscribes from the other`` () = + let boom : exn = InvalidOperationException "boom" + use left = new Subject () + use right = new Subject () + let leftProbe = SubscriptionProbe () + + use recorder = + Observable.merge (leftProbe.Watch left, right) + |> Recorder.Attach + + left.OnNext 1 + right.OnCompleted (Result.Failure boom) + + CollectionAssert.AreEqual ([| 1 |], recorder.Values, "merge must forward only the element that arrived before the failure") + Assert.IsEmpty (recorder.Errors, "merge must not turn the failure into an error reported through OnErrorResume") + assertFailedWith boom recorder "merge must fail with the failure of either source" + // The completed Recorder ignores every later notification, so it cannot show that merge stopped forwarding; the left + // subject stays open, so this disposal proves that the failure tore its subscription down + Assert.AreEqual (1, leftProbe.Disposed, "merge must unsubscribe from the other source when one fails") + + [] + member _.``disposing a merge subscription unsubscribes from both sources`` () = + use left = new Subject () + use right = new Subject () + let probe = SubscriptionProbe () + + use recorder = + Observable.merge (probe.Watch left, probe.Watch right) + |> Recorder.Attach + + left.OnNext 1 + CollectionAssert.AreEqual ([| 1 |], recorder.Values, "merge must forward the elements while subscribed") + recorder.Dispose () + + Assert.AreEqual (2, probe.Subscribed, "merge must subscribe to both sources") + // The disposed Recorder ignores every notification, so only the probe can show the unsubscription; both subjects + // stay open, so these disposals prove it + Assert.AreEqual (2, probe.Disposed, "Disposing the subscription must unsubscribe from both sources") + + [] + member _.``ofType keeps the elements of the target type and silently drops the others`` () = + use recorder = + Sources.values [| "a" :> obj; 1 :> obj; "b" :> obj |] + |> Observable.ofType + |> Recorder.Attach + + CollectionAssert.AreEqual ([| "a"; "b" |], recorder.Values, "ofType must emit the elements of the target type") + // Unlike Cast, R3 OfType filters with a type test, so the other elements are not reported as errors + Assert.IsEmpty (recorder.Errors, "ofType must drop the other elements without reporting an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "ofType must complete with the source") + + [] + member _.``singleton emits its value synchronously to every subscriber and completes`` () = + let single = Observable.singleton 42 + + // R3 Return without a time provider emits and completes inside Subscribe + use first = Recorder.Attach single + CollectionAssert.AreEqual ([| 42 |], first.Values, "singleton must emit its value when subscribed") + Assert.IsEmpty (first.Errors, "singleton must not report an error") + Assert.IsTrue (first.IsCompletedSuccessfully, "singleton must complete right after its value") + + use second = Recorder.Attach single + CollectionAssert.AreEqual ([| 42 |], second.Values, "singleton must emit its value again to a later subscriber") + Assert.IsEmpty (second.Errors, "singleton must not report an error to a later subscriber") + Assert.IsTrue (second.IsCompletedSuccessfully, "singleton must complete again for a later subscriber") + + [] + member _.``skip bypasses the first elements and emits the rest`` () = + use recorder = + Sources.values [| 1; 2; 3; 4; 5 |] + |> Observable.skip 2 + |> Recorder.Attach + + CollectionAssert.AreEqual ([| 3; 4; 5 |], recorder.Values, "skip must bypass the first two elements") + Assert.IsEmpty (recorder.Errors, "skip must not report an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "skip must complete with the source") + + [] + member _.``skip more elements than the source emits completes without values`` () = + use recorder = + Sources.values [| 1; 2; 3 |] + |> Observable.skip 10 + |> Recorder.Attach + + Assert.IsEmpty (recorder.Values, "skip must bypass every element of a shorter source") + Assert.IsEmpty (recorder.Errors, "skip must not report an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "skip must complete with the source") + + [] + member _.``take emits the first elements and completes`` () = + use recorder = + Sources.values [| 1; 2; 3; 4; 5 |] + |> Observable.take 2 + |> Recorder.Attach + + CollectionAssert.AreEqual ([| 1; 2 |], recorder.Values, "take must emit the first two elements") + Assert.IsEmpty (recorder.Errors, "take must not report an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "take must complete after the last taken element") + + [] + member _.``take completes and unsubscribes from a hot source after the last taken value`` () = + use subject = new Subject () + let probe = SubscriptionProbe () + + use recorder = + subject + |> probe.Watch + |> Observable.take 2 + |> Recorder.Attach + + subject.OnNext 1 + Assert.IsTrue (recorder.Completion.IsNone, "take must stay open before the last taken element") + Assert.AreEqual (0, probe.Disposed, "take must keep its subscription before the last taken element") + + subject.OnNext 2 + + CollectionAssert.AreEqual ([| 1; 2 |], recorder.Values, "take must emit the first two elements") + Assert.IsEmpty (recorder.Errors, "take must not report an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "take must complete right after the last taken element") + // Nothing is pushed after the last taken element: the completed Recorder would ignore it. The subject never + // completes by itself, so this disposal proves that take unsubscribed + Assert.AreEqual (1, probe.Disposed, "take must unsubscribe from the source after the last taken element") + + [] + member _.``take zero completes at once without subscribing to the source`` () = + use subject = new Subject () + let probe = SubscriptionProbe () + + use recorder = + subject + |> probe.Watch + |> Observable.take 0 + |> Recorder.Attach + + // R3 Take returns Observable.Empty for a count of zero, so the source is never subscribed + Assert.IsEmpty (recorder.Values, "take 0 must not emit") + Assert.IsEmpty (recorder.Errors, "take 0 must not report an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "take 0 must complete on subscription") + Assert.AreEqual (0, probe.Subscribed, "take 0 must not subscribe to the source") + + [] + member _.``skip and take reject a negative count when called`` () = + use subject = new Subject () + + // R3 validates the count when the operator is created, before anything subscribes + Assert.Throws(Action (fun () -> subject |> Observable.skip (-1) |> ignore), "skip must reject a negative count") + |> ignore + + Assert.Throws(Action (fun () -> subject |> Observable.take (-1) |> ignore), "take must reject a negative count") + |> ignore + + [] + member _.``choose emits the values of ValueSome results and calls the chooser once per element`` () = + let calls = ref 0 + + use recorder = + Sources.values [| 1; 2; 3; 4; 5; 6 |] + |> Observable.choose (fun x -> + calls.Value <- calls.Value + 1 + if x % 2 = 0 then ValueSome (x * 10) else ValueNone + ) + |> Recorder.Attach + + CollectionAssert.AreEqual ([| 20; 40; 60 |], recorder.Values, "choose must emit the values of the ValueSome results") + Assert.AreEqual (6, calls.Value, "choose must call the chooser exactly once per element") + Assert.IsEmpty (recorder.Errors, "choose must not report an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "choose must complete with the source") + + [] + member _.``ObservableOption choose emits the values of Some results and calls the chooser once per element`` () = + let calls = ref 0 + + use recorder = + Sources.values [| 1; 2; 3; 4; 5; 6 |] + |> ObservableOption.choose (fun x -> + calls.Value <- calls.Value + 1 + if x % 2 = 0 then Some (x * 10) else None + ) + |> Recorder.Attach + + CollectionAssert.AreEqual ([| 20; 40; 60 |], recorder.Values, "ObservableOption.choose must emit the values of the Some results") + Assert.AreEqual (6, calls.Value, "ObservableOption.choose must call the chooser exactly once per element") + Assert.IsEmpty (recorder.Errors, "ObservableOption.choose must not report an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "ObservableOption.choose must complete with the source") + + [] + member _.``choose forwards the failure of a hot source`` () = + let boom : exn = InvalidOperationException "boom" + use subject = new Subject () + + use recorder = + subject + |> Observable.choose (fun x -> if x > 1 then ValueSome (x * 10) else ValueNone) + |> Recorder.Attach + + subject.OnNext 1 + subject.OnNext 2 + subject.OnCompleted (Result.Failure boom) + + CollectionAssert.AreEqual ([| 20 |], recorder.Values, "choose must emit the values chosen before the failure") + Assert.IsEmpty (recorder.Errors, "choose must not turn the failure into an error reported through OnErrorResume") + assertFailedWith boom recorder "choose must fail with the failure of the source" + + [] + member _.``ObservableOption choose forwards the failure of a hot source`` () = + let boom : exn = InvalidOperationException "boom" + use subject = new Subject () + + use recorder = + subject + |> ObservableOption.choose (fun x -> if x > 1 then Some (x * 10) else None) + |> Recorder.Attach + + subject.OnNext 1 + subject.OnNext 2 + subject.OnCompleted (Result.Failure boom) + + CollectionAssert.AreEqual ([| 20 |], recorder.Values, "ObservableOption.choose must emit the values chosen before the failure") + Assert.IsEmpty (recorder.Errors, "ObservableOption.choose must not turn the failure into an error reported through OnErrorResume") + assertFailedWith boom recorder "ObservableOption.choose must fail with the failure of the source" + + [] + member _.``choose reports a chooser exception through OnErrorResume and keeps choosing`` () = + let boom : exn = InvalidOperationException "boom" + + use recorder = + Sources.values [| 1; 2; 3 |] + |> Observable.choose (fun x -> if x = 2 then raise boom else ValueSome (x * 10)) + |> Recorder.Attach + + // choose maps through R3 Select, whose observer reports an exception of the chooser through OnErrorResume + CollectionAssert.AreEqual ([| 10; 30 |], recorder.Values, "choose must keep choosing after the chooser throws") + CollectionAssert.AreEqual ([| boom |], recorder.Errors, "choose must report the exception of the chooser") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "choose must complete with the source") + + [] + member _.``ObservableOption choose reports a chooser exception through OnErrorResume and keeps choosing`` () = + let boom : exn = InvalidOperationException "boom" + + use recorder = + Sources.values [| 1; 2; 3 |] + |> ObservableOption.choose (fun x -> if x = 2 then raise boom else Some (x * 10)) + |> Recorder.Attach + + // ObservableOption.choose maps through R3 Select as well, so an exception of the chooser is reported through OnErrorResume + CollectionAssert.AreEqual ([| 10; 30 |], recorder.Values, "ObservableOption.choose must keep choosing after the chooser throws") + CollectionAssert.AreEqual ([| boom |], recorder.Errors, "ObservableOption.choose must report the exception of the chooser") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "ObservableOption.choose must complete with the source") + + [] + member _.``ofSeq emits the elements of a sequence synchronously and completes`` () = + use recorder = Recorder.Attach (Observable.ofSeq [ 1; 2; 3 ]) + + // R3 ToObservable enumerates inside Subscribe, so the outcome is known as soon as Attach returns + CollectionAssert.AreEqual ([| 1; 2; 3 |], recorder.Values, "ofSeq must emit the elements of the sequence in order") + Assert.IsEmpty (recorder.Errors, "ofSeq must not report an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "ofSeq must complete after the last element") + + [] + member _.``ofSeq applied in a pipeline emits the elements of the sequence`` () = + use recorder = [ 1; 2; 3 ] |> Observable.ofSeq |> Recorder.Attach + + CollectionAssert.AreEqual ([| 1; 2; 3 |], recorder.Values, "A piped ofSeq must emit the elements of the sequence in order") + Assert.IsEmpty (recorder.Errors, "A piped ofSeq must not report an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "A piped ofSeq must complete after the last element") + + [] + member _.``ofSeq enumerates the sequence again for every subscription`` () = + let enumerations = ref 0 + + let items = seq { + enumerations.Value <- enumerations.Value + 1 + yield! [ 1; 2 ] } - |> Async.StartImmediateAsTask - :> Task + let source = Observable.ofSeq items + use first = Recorder.Attach source + use second = Recorder.Attach source + + Assert.AreEqual (2, enumerations.Value, "ofSeq must enumerate the sequence once per subscription") + CollectionAssert.AreEqual ([| 1; 2 |], first.Values, "The first subscription must receive every element") + CollectionAssert.AreEqual ([| 1; 2 |], second.Values, "A later subscription must receive every element again") + Assert.IsEmpty (first.Errors, "ofSeq must not report an error to the first subscription") + Assert.IsEmpty (second.Errors, "ofSeq must not report an error to the later subscription") + Assert.IsTrue (first.IsCompletedSuccessfully, "ofSeq must complete the first subscription") + Assert.IsTrue (second.IsCompletedSuccessfully, "ofSeq must complete the later subscription") + + [] + member _.``ofSeq over an empty sequence completes successfully without emitting`` () = + use recorder = Recorder.Attach (Observable.ofSeq Array.empty) + + Assert.IsEmpty (recorder.Values, "ofSeq must not emit for an empty sequence") + Assert.IsEmpty (recorder.Errors, "ofSeq must not report an error for an empty sequence") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "ofSeq must complete successfully for an empty sequence") [] - member _.``Test filter`` () = + member _.``ofSeq with an already cancelled token completes successfully without emitting`` () = + use cancellation = new CancellationTokenSource () + cancellation.Cancel () + + use recorder = + Observable.ofSeq ([ 1; 2; 3 ], cancellation.Token) + |> Recorder.Attach - let mutable hasvisited = false - use r3Bus = new R3.Subject () - let interesting = - r3Bus - |> FSharp.Control.R3.Observable.filter (fun x -> x % 2 = 0) + // R3 ToObservable checks the token before emitting every element and then completes with Success, + // not with a cancellation + Assert.IsEmpty (recorder.Values, "ofSeq must not emit when its token is already cancelled") + Assert.IsEmpty (recorder.Errors, "ofSeq must not report the cancellation as an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "ofSeq must complete successfully when its token is already cancelled") - // No-one listens yet (vs R3.ReplaySubject - r3Bus.OnNext 2 + [] + member _.``ofSeq completes successfully when its token is cancelled during the enumeration`` () = + use cancellation = new CancellationTokenSource () - use subscription = - R3.ObservableExtensions.SubscribeAwait ( - interesting, - fun i cancellationToken -> - task { - // Listen events + let items = seq { + yield 1 + yield 2 + cancellation.Cancel () + yield 3 + yield 4 + } - hasvisited <- true + use recorder = + Observable.ofSeq (items, cancellation.Token) + |> Recorder.Attach - Assert.AreEqual(4, i) + // R3 ToObservable checks the token after taking each element from the enumerator and before emitting it, + // so the element produced after the cancellation is not emitted + CollectionAssert.AreEqual ([| 1; 2 |], recorder.Values, "ofSeq must stop emitting once its token is cancelled") + Assert.IsEmpty (recorder.Errors, "ofSeq must not report the cancellation as an error") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "ofSeq must complete successfully when its token is cancelled") - return () - } - |> System.Threading.Tasks.ValueTask + [] + member _.``ofSeq lets an exception of the enumeration escape from Subscribe`` () = + let boom : exn = InvalidOperationException "boom" + + let items = seq { + yield 1 + raise boom + } + + let source = Observable.ofSeq items + use recorder = new Recorder () + + // R3 ToObservable has no try/catch around the enumeration: the exception escapes from Subscribe instead of failing + // the sequence, and Subscribe disposes the observer before rethrowing it + let thrown = + Assert.Throws( + Action (fun () -> source.Subscribe recorder |> ignore), + "The exception of the enumeration must escape from Subscribe" ) - // Publish some events, "4" should be heard - [ 3..5 ] |> List.iter r3Bus.OnNext - Assert.AreEqual(true, hasvisited) + Assert.AreSame(boom, thrown, "Subscribe must rethrow the exception of the enumeration itself") + CollectionAssert.AreEqual ([| 1 |], recorder.Values, "The elements before the exception must have been emitted") + Assert.IsEmpty (recorder.Errors, "The exception must not be reported through OnErrorResume") + Assert.IsTrue (recorder.Completion.IsNone, "The exception must not complete the sequence") + + [] + member _.``ofSeq enumerates the whole sequence even after a downstream take has completed`` () = + let pulled = ref 0 + + let items = seq { + for i in 1..5 do + pulled.Value <- pulled.Value + 1 + yield i + } + + use recorder = + Observable.ofSeq items + |> Observable.take 2 + |> Recorder.Attach + + CollectionAssert.AreEqual ([| 1; 2 |], recorder.Values, "take must emit only the first two elements") + Assert.IsEmpty (recorder.Errors, "No error may be reported") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "take must complete after the last taken element") + // R3 ToObservable checks only its own token, never whether the observer was disposed, so it keeps pulling the + // remaining elements after take has completed; an infinite sequence would never return from Subscribe + Assert.AreEqual (5, pulled.Value, "ofSeq must enumerate the sequence to the end") + + [] + member _.``filter, map and take compose on a hot subject and unsubscribe after the last taken value`` () = + use subject = new Subject () + let probe = SubscriptionProbe () + + use recorder = + subject + |> probe.Watch + |> Observable.filter (fun x -> x % 2 = 0) + |> Observable.map (fun x -> x * 10) + |> Observable.take 2 + |> Recorder.Attach + + subject.OnNext 1 + subject.OnNext 2 + Assert.IsTrue (recorder.Completion.IsNone, "The pipeline must stay open until two values have passed the filter") + subject.OnNext 3 + subject.OnNext 4 + + CollectionAssert.AreEqual ([| 20; 40 |], recorder.Values, "Only the even values, multiplied by 10, must arrive") + Assert.IsEmpty (recorder.Errors, "No error may be reported") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "take must complete successfully after its second value") + // Nothing is pushed after the second value: the completed Recorder would ignore it. The subject never completes by + // itself, so this disposal proves that take unsubscribed from the whole chain + Assert.AreEqual (1, probe.Disposed, "take must dispose its upstream subscription once satisfied") diff --git a/tests/FSharp.Control.R3.Tests/ProcessingOptionsTests.fs b/tests/FSharp.Control.R3.Tests/ProcessingOptionsTests.fs new file mode 100644 index 0000000..7969861 --- /dev/null +++ b/tests/FSharp.Control.R3.Tests/ProcessingOptionsTests.fs @@ -0,0 +1,86 @@ +namespace FSharp.Control.R3.Tests + +open Microsoft.VisualStudio.TestTools.UnitTesting +open FSharp.Reflection +open R3 +open FSharp.Control.R3 + +/// +/// Every case with the that +/// selects for it and the concurrency limit that +/// reports for it. +/// +module private ProcessingOptionsTestCases = + + let all = [| + // R3 SelectAwait ignores the concurrency limit of the modes that do not run in parallel, so they report no limit + struct (AwaitSequential, AwaitOperation.Sequential, -1) + struct (AwaitDrop, AwaitOperation.Drop, -1) + struct (AwaitSwitch, AwaitOperation.Switch, -1) + // -1 is the value R3 SelectAwait uses for no concurrency limit + struct (AwaitParallel -1, AwaitOperation.Parallel, -1) + struct (AwaitParallel 1, AwaitOperation.Parallel, 1) + struct (AwaitParallel 3, AwaitOperation.Parallel, 3) + struct (AwaitSequentialParallel -1, AwaitOperation.SequentialParallel, -1) + struct (AwaitSequentialParallel 4, AwaitOperation.SequentialParallel, 4) + struct (AwaitThrottleFirstLast, AwaitOperation.ThrottleFirstLast, -1) + |] + +[] +type ProcessingOptionsTests () = + + [] + member _.``Default is AwaitSequential with ConfigureAwait and without CancelOnCompleted`` () = + let options = ProcessingOptions.Default + + let expected = { + AwaitOperationConfiguration = AwaitSequential + ConfigureAwait = true + CancelOnCompleted = false + } + + Assert.AreEqual (expected, options, "Default must run one invocation at a time, capture the context and let invocations finish") + Assert.AreEqual (-1, options.MaxConcurrent, "A sequential configuration has no concurrency limit to report") + Assert.AreEqual (AwaitOperation.Sequential, options.AwaitOperation, "Default must select the sequential R3 operation") + + [] + member _.``Parallel is AwaitParallel without a limit, with ConfigureAwait and without CancelOnCompleted`` () = + let options = ProcessingOptions.Parallel + + let expected = { + AwaitOperationConfiguration = AwaitParallel -1 + ConfigureAwait = true + CancelOnCompleted = false + } + + Assert.AreEqual (expected, options, "Parallel must run every invocation at once, capture the context and let invocations finish") + Assert.AreEqual (-1, options.MaxConcurrent, "Parallel must report -1, the R3 value for no concurrency limit") + Assert.AreEqual (AwaitOperation.Parallel, options.AwaitOperation, "Parallel must select the parallel R3 operation") + + [] + member _.``AwaitOperation maps every AwaitOperationConfiguration case to the matching R3 AwaitOperation`` () = + // The table is checked against the union itself, so a case added later cannot stay untested + let caseName (configuration : AwaitOperationConfiguration) = + (fst (FSharpValue.GetUnionFields (configuration, typeof))).Name + + let testedCases = + ProcessingOptionsTestCases.all + |> Seq.map (fun struct (configuration, _, _) -> caseName configuration) + |> Seq.distinct + |> Seq.toArray + + let allCases = + FSharpType.GetUnionCases typeof + |> Array.map _.Name + + CollectionAssert.AreEquivalent (allCases, testedCases, "The table must contain every AwaitOperationConfiguration case") + + for struct (configuration, expected, _) in ProcessingOptionsTestCases.all do + let options = { ProcessingOptions.Default with AwaitOperationConfiguration = configuration } + Assert.AreEqual (expected, options.AwaitOperation, $"%A{configuration} must select AwaitOperation.%A{expected}") + + [] + member _.``MaxConcurrent reports the limit of AwaitParallel and AwaitSequentialParallel and -1 for every other case`` () = + for struct (configuration, _, expected) in ProcessingOptionsTestCases.all do + let options = { ProcessingOptions.Default with AwaitOperationConfiguration = configuration } + Assert.AreEqual (expected, options.MaxConcurrent, $"%A{configuration} must report a MaxConcurrent of %d{expected}") diff --git a/tests/FSharp.Control.R3.Tests/TaskObservableTests.fs b/tests/FSharp.Control.R3.Tests/TaskObservableTests.fs new file mode 100644 index 0000000..ce67ad9 --- /dev/null +++ b/tests/FSharp.Control.R3.Tests/TaskObservableTests.fs @@ -0,0 +1,849 @@ +namespace FSharp.Control.R3.Tests + +open System +open System.Collections.Concurrent +open System.Linq +open System.Threading +open System.Threading.Tasks +open Microsoft.VisualStudio.TestTools.UnitTesting +open R3 +open FSharp.Control.R3 +open FSharp.Control.R3.Task +open FSharp.Control.R3.Tests.TestHelpers + +/// +/// Integration tests of and : +/// functions that subscribe as soon as they are called and take the cancellation token first. +/// +/// The selector modes of +/// +/// are covered by . +/// +/// +[] +type TaskObservableTests (testContext : TestContext) = + + // MSTest creates the test class for every test, so no two tests share this exception + let boom : exn = InvalidOperationException "boom" + + /// Checks that the recorded sequence completed with boom itself, without emitting anything before. + let assertFailedWithBoom (case : string) (recorder : Recorder<'T>) = + Assert.IsEmpty (recorder.Values, $"Nothing may be emitted for %s{case}") + Assert.IsEmpty (recorder.Errors, $"The failure of %s{case} must not be reported as a resumable error") + assertFailedWith boom recorder $"The sequence must complete with the exception of %s{case} itself" + + /// + /// Checks that nothing is emitted while the factory task of the sequence that + /// + /// or + /// created is still running, and that disposing the recorder cancels the factory token. + /// + /// A late result cannot be observed: an ignores every notification once it is disposed. + /// + /// + let assertDisposalCancelsFactory (recorder : Recorder<'T>) (factoryToken : CancellationToken ref) = + Assert.IsFalse (factoryToken.Value.IsCancellationRequested, "The factory token must not be cancelled while the subscription is alive") + Assert.IsEmpty (recorder.Values, "Nothing may be emitted while the factory task is running") + Assert.IsTrue (recorder.Completion.IsNone, "The sequence must not complete while the factory task is running") + recorder.Dispose () + // R3 passes the factory the token of a cancellation disposable, which is the subscription itself + Assert.IsTrue (factoryToken.Value.IsCancellationRequested, "Disposing the subscription must cancel the token passed to the factory") + + /// Subscribes, with a recording synchronization context installed, to the sequence that build creates over an incomplete task, + /// completes that task on a thread pool thread after the context is removed again, + /// and returns how many continuations were posted to the context. + let postsToCapturedContext (build : (CancellationToken -> ValueTask) -> Observable) : Task = task { + let context = RecordingSynchronizationContext () + let factoryTask = TaskCompletionSource() + use recorder = + context.Run (fun () -> + build (fun _ -> ValueTask factoryTask.Task) + |> Recorder.Attach + ) + // R3 started to await the task while the context was current. The thread pool thread that completes the task has no context, + // so a continuation that captured the context is posted to it, while one that did not runs on that thread + do! Task.Run (fun () -> factoryTask.SetResult 7) + let! completion = recorder.WaitForCompletionAsync testContext.CancellationToken + CollectionAssert.AreEqual ([| 7 |], recorder.Values, "The result of the task must be emitted once the task completes") + Assert.IsTrue (completion.IsSuccess, "The sequence must complete successfully after the result") + return context.Posts + } + + // iterAsync waits through iter, so it shares with the terminal functions the failures, cancellation and disposal + // that all of them get from R3's TaskObserverBase + /// Names of the terminal functions of the Task flavour, as the data rows of the tests that cover every one of them. + static member TerminalFunctions = dataRows [| yield! Terminals.names; "iterAsync" |] + + [] + member _.``length subscribes when called and counts only the elements pushed afterwards`` () : Task = task { + use subject = new Subject () + // A subject drops the elements pushed while nobody is subscribed + subject.OnNext 1 + let counting = Observable.length testContext.CancellationToken subject + subject.OnNext 2 + subject.OnNext 3 + Assert.IsFalse (counting.IsCompleted, "length must wait for the source to complete") + subject.OnCompleted (Result.Success) + let! count = counting + Assert.AreEqual (2, count, "length must count only the elements pushed after it was called") + } + + [] + member _.``length completes synchronously with the number of elements of a synchronous source`` () : Task = task { + let counting = + Sources.values [| 1; 2; 3 |] + |> Observable.length testContext.CancellationToken + // The source emits and completes inside Subscribe, so the task is already completed when length returns + Assert.IsTrue (counting.IsCompletedSuccessfully, "length must be completed by the time it returns for a synchronous source") + let! count = counting + Assert.AreEqual (3, count, "length must count every element") + } + + [] + member _.``length is zero for an empty source`` () : Task = task { + let! count = + (Observable.empty () : Observable) + |> Observable.length testContext.CancellationToken + Assert.AreEqual (0, count, "An empty source has no element to count") + } + + [] + member _.``length is cancelled and unsubscribes from a hot source when its token is cancelled`` () : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + use cancellation = CancellationTokenSource.CreateLinkedTokenSource testContext.CancellationToken + let counting = + subject + |> probe.Watch + |> Observable.length cancellation.Token + subject.OnNext 1 + Assert.IsFalse (counting.IsCompleted, "length must wait for the source to complete") + // R3 registers a callback on the token that runs inside Cancel: it disposes the subscription, then cancels the task + cancellation.Cancel () + Assert.AreEqual (TaskStatus.Canceled, counting.Status, "Cancel must cancel the task before it returns") + Assert.AreEqual (1, probe.Disposed, "Cancel must dispose the subscription to the source before it returns") + let! error = + Assert.ThrowsAsync((fun () -> counting :> Task), "Awaiting a cancelled length must throw") + Assert.AreEqual (cancellation.Token, error.CancellationToken, "The exception must carry the token that cancelled length") + } + + [] + member _.``aggregate folds the elements in order starting from the seed`` () : Task = task { + let! folded = + Sources.values [| 1; 2; 3 |] + |> Observable.aggregate testContext.CancellationToken "seed" (fun state x -> $"%s{state},%d{x}") + Assert.AreEqual ("seed,1,2,3", folded, "The accumulator must start from the seed and receive the elements in order") + } + + [] + member _.``aggregate returns the seed for an empty source`` () : Task = task { + let! folded = + (Observable.empty () : Observable) + |> Observable.aggregate testContext.CancellationToken "seed" (fun state x -> $"%s{state},%d{x}") + Assert.AreEqual ("seed", folded, "Without elements the seed must be the result") + } + + [] + member _.``aggregate faults with the exception of the accumulator and stops folding`` () : Task = task { + let folded = ResizeArray() + let folding = + Sources.values [| 1; 2; 3 |] + |> Observable.aggregate + testContext.CancellationToken + 0 + (fun state x -> + folded.Add x + if x = 2 then + raise boom + state + x + ) + // R3 turns the exception of the accumulator into an error that faults the task and disposes the observer, + // which then ignores the element that the synchronous source still pushes + let! error = + Assert.ThrowsAsync((fun () -> folding :> Task), "aggregate must fault when the accumulator throws") + Assert.AreSame (boom, error, "aggregate must fault with the exception of the accumulator itself") + CollectionAssert.AreEqual ([| 1; 2 |], folded.ToArray (), "The accumulator must not be called after it threw") + } + + [] + member _.``all is true when every element satisfies the predicate`` () : Task = task { + let! result = + Sources.values [| 1; 2; 3 |] + |> Observable.all testContext.CancellationToken (fun x -> x > 0) + Assert.IsTrue (result, "all must be true when no element fails the predicate") + } + + [] + member _.``all is true for an empty source`` () : Task = task { + let! result = + (Observable.empty () : Observable) + |> Observable.all testContext.CancellationToken (fun x -> x > 0) + Assert.IsTrue (result, "all must be true when there is no element to fail the predicate") + } + + [] + member _.``all returns false at the first element that fails the predicate and unsubscribes from the source`` () : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + let tested = ResizeArray() + let allPositive = + subject + |> probe.Watch + |> Observable.all + testContext.CancellationToken + (fun x -> + tested.Add x + x > 0 + ) + subject.OnNext 1 + Assert.IsFalse (allPositive.IsCompleted, "all must wait while every element satisfies the predicate") + // R3 completes the task on the deciding element and disposes the subscription at once + subject.OnNext (-1) + Assert.IsTrue (allPositive.IsCompletedSuccessfully, "all must complete on the first element that fails the predicate") + Assert.AreEqual (1, probe.Disposed, "all must dispose the subscription to the source once it is decided") + subject.OnNext 2 + CollectionAssert.AreEqual ([| 1; -1 |], tested.ToArray (), "The predicate must not be called after the result is decided") + let! result = allPositive + Assert.IsFalse (result, "all must be false when an element fails the predicate") + } + + [] + member _.``existsAsync is false for an empty source`` () : Task = task { + let! result = + (Observable.empty () : Observable) + |> Observable.existsAsync testContext.CancellationToken + Assert.IsFalse (result, "existsAsync must be false when the source completes without an element") + } + + [] + member _.``existsAsync returns true at the first element and unsubscribes from the source`` () : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + let exists = + subject + |> probe.Watch + |> Observable.existsAsync testContext.CancellationToken + Assert.IsFalse (exists.IsCompleted, "existsAsync must wait for an element") + // R3 completes the task on the first element and disposes the subscription at once + subject.OnNext 5 + Assert.IsTrue (exists.IsCompletedSuccessfully, "existsAsync must complete on the first element") + Assert.AreEqual (1, probe.Disposed, "existsAsync must dispose the subscription to the source once it is decided") + let! result = exists + Assert.IsTrue (result, "existsAsync must be true when the source has an element") + } + + [] + member _.``firstAsync returns the first element and unsubscribes from the source`` () : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + let first = + subject + |> probe.Watch + |> Observable.firstAsync testContext.CancellationToken + Assert.IsFalse (first.IsCompleted, "firstAsync must wait for an element") + // R3 completes the task on the first element and disposes the subscription at once + subject.OnNext 5 + Assert.AreEqual (1, probe.Disposed, "firstAsync must dispose the subscription to the source once it has the first element") + subject.OnNext 6 + let! result = first + Assert.AreEqual (5, result, "firstAsync must return the first element") + } + + [] + member _.``firstAsync faults with InvalidOperationException for an empty source`` () : Task = task { + let first = + (Observable.empty () : Observable) + |> Observable.firstAsync testContext.CancellationToken + let! _ = + Assert.ThrowsAsync((fun () -> first :> Task), "firstAsync must fault when the source has no element") + () + } + + [] + member _.``iter invokes the action for every element in order`` () : Task = task { + let seen = ResizeArray() + let iteration = + Sources.values [| 1; 2; 3 |] + |> Observable.iter testContext.CancellationToken seen.Add + // The source emits and completes inside Subscribe, so the task is already completed when iter returns + Assert.IsTrue (iteration.IsCompletedSuccessfully, "iter must be completed by the time it returns for a synchronous source") + do! iteration + CollectionAssert.AreEqual ([| 1; 2; 3 |], seen.ToArray (), "The action must receive every element in order") + } + + [] + member _.``iter completes without invoking the action for an empty source`` () : Task = task { + let seen = ResizeArray() + do! + (Observable.empty () : Observable) + |> Observable.iter testContext.CancellationToken seen.Add + Assert.IsEmpty (seen, "The action must not be invoked without elements") + } + + [] + member _.``iter faults with the exception of the action and stops processing`` () : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + let seen = ResizeArray() + let iteration = + subject + |> probe.Watch + |> Observable.iter + testContext.CancellationToken + (fun x -> + if x = 2 then + raise boom + seen.Add x + ) + subject.OnNext 1 + // R3 turns the exception of the action into an error that faults the task and disposes the subscription + subject.OnNext 2 + Assert.AreEqual (1, probe.Disposed, "iter must dispose the subscription to the source when the action throws") + subject.OnNext 3 + let! error = + Assert.ThrowsAsync((fun () -> iteration), "iter must fault when the action throws") + Assert.AreSame (boom, error, "iter must fault with the exception of the action itself") + CollectionAssert.AreEqual ([| 1 |], seen.ToArray (), "No element may be processed after the action threw") + } + + [] + member _.``iter with an already cancelled token is cancelled without invoking the action for a synchronous source`` () : Task = task { + let seen = ResizeArray() + use cancellation = new CancellationTokenSource () + cancellation.Cancel () + let iteration = + Sources.values [| 1; 2; 3 |] + |> Observable.iter cancellation.Token seen.Add + // R3 registers on the token before it subscribes, so the observer is disposed before the source emits + Assert.AreEqual (TaskStatus.Canceled, iteration.Status, "iter must be cancelled by the time it returns") + Assert.IsEmpty (seen, "The action must not be invoked for elements emitted after the cancellation") + let! error = + Assert.ThrowsAsync((fun () -> iteration), "Awaiting a cancelled iter must throw") + Assert.AreEqual (cancellation.Token, error.CancellationToken, "The exception must carry the token that cancelled iter") + } + + [] + member _.``mapAsync with the default options projects every element in order and completes`` () : Task = task { + use recorder = + Sources.values [| 1; 2; 3 |] + |> Observable.mapAsync ProcessingOptions.Default (fun _ x -> Task.FromResult (x * 10)) + |> Recorder.Attach + let! completion = recorder.WaitForCompletionAsync testContext.CancellationToken + // The default options run the selector for one element at a time, so the results keep the order of the source + CollectionAssert.AreEqual ([| 10; 20; 30 |], recorder.Values, "Every element must be projected in order") + Assert.IsEmpty (recorder.Errors, "No error may be reported") + Assert.IsTrue (completion.IsSuccess, "The projected sequence must complete successfully with the source") + } + + [] + member _.``iterAsync invokes the action for every element in order and completes with the source`` () : Task = task { + let seen = ConcurrentQueue() + do! + Sources.values [| 1; 2; 3 |] + |> Observable.iterAsync testContext.CancellationToken ProcessingOptions.Default (fun _ x -> task { seen.Enqueue x }) + // The default options run the action for one element at a time, in the order of the source + CollectionAssert.AreEqual ([| 1; 2; 3 |], seen.ToArray (), "The action must receive every element in order") + } + + [] + member _.``iterAsync completes without invoking the action for an empty source`` () : Task = task { + let seen = ConcurrentQueue() + do! + (Observable.empty () : Observable) + |> Observable.iterAsync testContext.CancellationToken ProcessingOptions.Default (fun _ x -> task { seen.Enqueue x }) + Assert.IsEmpty (seen, "The action must not be invoked without elements") + } + + [] + member _.``iterAsync faults with the exception of the action and stops invoking it`` () : Task = task { + use subject = new Subject () + let invoked = ConcurrentQueue() + let iteration = + subject + |> Observable.iterAsync + testContext.CancellationToken + ProcessingOptions.Default + (fun _ x -> task { + invoked.Enqueue x + if x = 2 then + raise boom + }) + subject.OnNext 1 + subject.OnNext 2 + // mapAsync reports the exception of the action as an error, which faults the task and disposes the subscription + let! error = + Assert.ThrowsAsync((fun () -> iteration), "iterAsync must fault when the action throws") + Assert.AreSame (boom, error, "iterAsync must fault with the exception of the action itself") + // The disposal cancels the token that the worker of mapAsync checks before it takes the next element + subject.OnNext 3 + CollectionAssert.AreEqual ([| 1; 2 |], invoked.ToArray (), "The action must not be invoked after it threw") + } + + [] + member _.``iterAsync stops invoking the action after it threw even when a synchronous source emits during the subscription`` () : Task = task { + let invoked = ConcurrentQueue() + // The source emits every element while iterAsync subscribes, before R3 could dispose the mapAsync stage, + // so it is the library that skips the elements after the failure + let iteration = + Sources.values [| 1; 2; 3; 4; 5 |] + |> Observable.iterAsync + testContext.CancellationToken + ProcessingOptions.Default + (fun _ x -> task { + invoked.Enqueue x + if x = 2 then + raise boom + }) + let! error = + Assert.ThrowsAsync((fun () -> iteration), "iterAsync must fault when the action throws") + Assert.AreSame (boom, error, "iterAsync must fault with the exception of the action itself") + CollectionAssert.AreEqual ([| 1; 2 |], invoked.ToArray (), "The action must not be invoked after it threw") + } + + [] + member _.``iterAsync faults with a failure of the action that happens after the source completed`` () : Task = task { + let gate = TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously) + let iteration = + Sources.values [| 1 |] + |> Observable.iterAsync + testContext.CancellationToken + ProcessingOptions.Default + (fun _ _ -> task { + do! gate.Task + raise boom + }) + // The source has completed while the action still runs + Assert.IsFalse (iteration.IsCompleted, "iterAsync must wait for the running action") + // R3 drops an error that mapAsync reports after its source completed and would complete the iteration successfully; + // the library faults the iteration with the failure it recorded when the action threw + gate.SetResult () + let! error = + Assert.ThrowsAsync((fun () -> iteration), "iterAsync must fault with the late failure of the action") + Assert.AreSame (boom, error, "iterAsync must fault with the exception of the action itself") + } + + [] + member _.``iterAsync stops at an OperationCanceledException that the action throws by itself instead of hanging`` () : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + let invoked = ConcurrentQueue() + // Like the TaskCanceledException of an HttpClient timeout: thrown while the token passed to the action is not cancelled + let timeout : exn = TaskCanceledException "timeout" + let iteration = + subject + |> probe.Watch + |> Observable.iterAsync + testContext.CancellationToken + ProcessingOptions.Default + (fun _ x -> task { + invoked.Enqueue x + if x = 2 then + raise timeout + }) + subject.OnNext 1 + subject.OnNext 2 + // R3 swallows an OperationCanceledException of the selector, which stops its sequential worker for good without completing; + // the library ends the iteration with it instead, and a task that ends with an OperationCanceledException is cancelled + let! error = + Assert.ThrowsAsync((fun () -> iteration), "iterAsync must end with the exception of the action") + Assert.AreSame (timeout, error, "iterAsync must end with the exception of the action itself") + Assert.IsTrue (iteration.IsCanceled, "A task that ends with an OperationCanceledException must be cancelled") + subject.OnNext 3 + CollectionAssert.AreEqual ([| 1; 2 |], invoked.ToArray (), "The action must not be invoked after it threw") + // R3 completes the task of the terminal operator that iterAsync waits through before it disposes the subscription, + // and awaiting the iteration may resume in between, so the disposal is awaited rather than raced + do! probe.WaitForDisposedAsync (testContext.CancellationToken, 1) + Assert.AreEqual (0, probe.Active, "The failure of the action must unsubscribe from the source") + } + + [] + member _.``iterAsync completes successfully when an action superseded by AwaitSwitch throws a cancellation`` () : Task = task { + use subject = new Subject () + // Without RunContinuationsAsynchronously, releasing the first gate resumes its action inline, + // so the action has thrown its cancellation, and the library has handled it, by the time SetResult returns + let firstGate = TaskCompletionSource () + let lastGate = TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously) + let options = { ProcessingOptions.Default with AwaitOperationConfiguration = AwaitSwitch } + let iteration = + subject + |> Observable.iterAsync + testContext.CancellationToken + options + (fun cancellationToken x -> task { + if x = 1 then + do! firstGate.Task + // A well-behaved action that notices its cancellation after a wait that does not observe the token + cancellationToken.ThrowIfCancellationRequested () + else + do! lastGate.Task + }) + subject.OnNext 1 + // AwaitSwitch cancels the token of the running first action when the next element arrives + subject.OnNext 2 + firstGate.SetResult () + subject.OnCompleted (Result.Success) + lastGate.SetResult () + // R3 swallows the cancellation of a superseded action, and the library must not count it as a failure either + do! iteration + Assert.IsTrue (iteration.IsCompletedSuccessfully, "iterAsync must complete successfully when only a superseded action was cancelled") + } + + [] + member _.``iterAsync with an already cancelled token is cancelled without invoking the action for a synchronous source`` () : Task = task { + let invoked = ConcurrentQueue() + let probe = SubscriptionProbe () + use cancellation = new CancellationTokenSource () + cancellation.Cancel () + let iteration = + Sources.values [| 1; 2; 3 |] + |> probe.Watch + |> Observable.iterAsync cancellation.Token ProcessingOptions.Default (fun _ x -> task { invoked.Enqueue x }) + // The guard already skips the action once the iteration token is cancelled; the shortcut is what keeps + // the library from subscribing at all, so a cold source does no work + Assert.AreEqual (0, probe.Subscribed, "iterAsync must not subscribe to the source with an already cancelled token") + Assert.AreEqual (TaskStatus.Canceled, iteration.Status, "iterAsync must be cancelled by the time it returns") + Assert.IsEmpty (invoked, "The action must not be invoked with an already cancelled token") + let! error = + Assert.ThrowsAsync((fun () -> iteration), "Awaiting a cancelled iterAsync must throw") + Assert.AreEqual (cancellation.Token, error.CancellationToken, "The exception must carry the token that cancelled iterAsync") + } + + [] + member _.``iterAsync cancellation cancels the token of the running action and unsubscribes from the source`` () : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + let action = GatedSelector ignore + use cancellation = CancellationTokenSource.CreateLinkedTokenSource testContext.CancellationToken + let iteration = + subject + |> probe.Watch + |> Observable.iterAsync cancellation.Token ProcessingOptions.Default action.InvokeTask + subject.OnNext 1 + do! action.WaitForStartedAsync (testContext.CancellationToken, 1) + Assert.IsFalse (action.Tokens[0].IsCancellationRequested, "The token of the running action must not be cancelled before Cancel") + // R3 disposes the iteration inside Cancel, which disposes mapAsync: that cancels the token of the running action + // and unsubscribes from the source before the task is cancelled + cancellation.Cancel () + Assert.AreEqual (TaskStatus.Canceled, iteration.Status, "Cancel must cancel the task before it returns") + Assert.IsTrue (action.Tokens[0].IsCancellationRequested, "Cancel must cancel the token of the running action") + Assert.AreEqual (1, probe.Disposed, "Cancel must dispose the subscription to the source before it returns") + let! error = + Assert.ThrowsAsync((fun () -> iteration), "Awaiting a cancelled iterAsync must throw") + Assert.AreEqual (cancellation.Token, error.CancellationToken, "The exception must carry the token that cancelled iterAsync") + } + + [] + member _.``iterAsync rejects a concurrency limit of 0 or below -1 when called`` () = + for limit in [| 0; -2 |] do + for configuration in [| AwaitParallel limit; AwaitSequentialParallel limit |] do + let options = { ProcessingOptions.Default with AwaitOperationConfiguration = configuration } + // iterAsync subscribes when it is called, so R3 would reject these limits at the call too, but with a plain + // ArgumentException; the exception type, the parameter name and the value come from the validation of the library + assertArgumentRejected + (Observable.iterAsync testContext.CancellationToken options (fun _ _ -> Task.FromResult ())) + "options" + limit + $"iterAsync must reject %A{configuration} when it is called" + + [] + member _.``ofTask emits the result of a completed ValueTask and completes synchronously`` () = + use recorder = + Observable.ofTask (fun _ -> ValueTask 42) + |> Recorder.Attach + // R3 awaits the task inside Subscribe, and the await of a completed task continues at once + CollectionAssert.AreEqual ([| 42 |], recorder.Values, "The result must be emitted once, before Subscribe returns") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "The sequence must complete successfully before Subscribe returns") + + [] + member _.``ofTask invokes the factory once for every subscription`` () = + let invocations = ref 0 + let source = + Observable.ofTask (fun _ -> + invocations.Value <- invocations.Value + 1 + ValueTask invocations.Value + ) + use first = Recorder.Attach source + use second = Recorder.Attach source + Assert.AreEqual (2, invocations.Value, "Every subscription must invoke the factory") + CollectionAssert.AreEqual ([| 1 |], first.Values, "The first subscription must receive the result of the first invocation") + CollectionAssert.AreEqual ([| 2 |], second.Values, "The second subscription must receive the result of the second invocation") + + [] + member _.``ofTask completes with the failure of a faulted task or of a throwing factory`` () = + use faulted = + Observable.ofTask (fun _ -> ValueTask(Task.FromException boom)) + |> Recorder.Attach + // Annotated, because a lambda that only raises fits both overloads of ofTask + use throwing = + Observable.ofTask (fun _ -> (raise boom : ValueTask)) + |> Recorder.Attach + // R3 invokes the factory and awaits its task inside one try block, and completes the sequence with the exception it catches + // unless that is a cancellation by the token of the subscription + assertFailedWithBoom "a faulted task" faulted + assertFailedWithBoom "a throwing factory" throwing + + [] + member _.``ofTask cancels the factory token when the subscription is disposed`` () = + let factoryTask = TaskCompletionSource() + let factoryToken = ref CancellationToken.None + use recorder = + Observable.ofTask (fun cancellationToken -> + factoryToken.Value <- cancellationToken + ValueTask factoryTask.Task + ) + |> Recorder.Attach + assertDisposalCancelsFactory recorder factoryToken + + [] + member _.``ofTask over a non-generic ValueTask emits a single Unit and completes synchronously`` () = + use recorder = + Observable.ofTask (fun _ -> ValueTask.CompletedTask) + |> Recorder.Attach + // R3 awaits the task inside Subscribe, and the await of a completed task continues at once + CollectionAssert.AreEqual ([| Unit.Default |], recorder.Values, "A single Unit must be emitted before Subscribe returns") + Assert.IsTrue (recorder.IsCompletedSuccessfully, "The sequence must complete successfully before Subscribe returns") + + [] + member _.``ofTask over a non-generic ValueTask completes with the failure of a faulted task or of a throwing factory`` () = + use faulted = + Observable.ofTask (fun _ -> ValueTask (Task.FromException boom)) + |> Recorder.Attach + // Annotated, because a lambda that only raises fits both overloads of ofTask + use throwing = + Observable.ofTask (fun _ -> (raise boom : ValueTask)) + |> Recorder.Attach + assertFailedWithBoom "a faulted task" faulted + assertFailedWithBoom "a throwing factory" throwing + + [] + member _.``ofTask over a non-generic ValueTask cancels the factory token when the subscription is disposed`` () = + let factoryTask = TaskCompletionSource () + let factoryToken = ref CancellationToken.None + use recorder = + Observable.ofTask (fun cancellationToken -> + factoryToken.Value <- cancellationToken + ValueTask factoryTask.Task + ) + |> Recorder.Attach + assertDisposalCancelsFactory recorder factoryToken + + [] + member _.``ofTask resumes on the captured synchronization context when configureAwait is omitted`` () : Task = task { + let! posts = postsToCapturedContext (fun factory -> Observable.ofTask factory) + // configureAwait defaults to true, like the configureAwait of R3's FromAsync + Assert.IsGreaterThanOrEqualTo (1, posts, "Without configureAwait the continuation must be posted to the captured context") + } + + [] + member _.``ofTask resumes on the captured synchronization context when configureAwait is true`` () : Task = task { + let! posts = postsToCapturedContext (fun factory -> Observable.ofTask (factory, true)) + Assert.IsGreaterThanOrEqualTo (1, posts, "With configureAwait the continuation must be posted to the captured context") + } + + [] + member _.``ofTask does not resume on the captured synchronization context when configureAwait is false`` () : Task = task { + let! posts = postsToCapturedContext (fun factory -> Observable.ofTask (factory, false)) + Assert.AreEqual (0, posts, "Without capturing the context nothing may be posted to it") + } + + [] + member _.``toArray collects every element in order`` () : Task = task { + let! values = + Sources.values [| 1; 2; 3 |] + |> Observable.toArray testContext.CancellationToken + CollectionAssert.AreEqual ([| 1; 2; 3 |], values, "toArray must collect every element in the order of the source") + } + + [] + member _.``toArray returns an empty array for an empty source`` () : Task = task { + let! values = + (Observable.empty () : Observable) + |> Observable.toArray testContext.CancellationToken + Assert.IsEmpty (values, "An empty source must give an empty array") + } + + [] + member _.``toList collects every element in order`` () : Task = task { + let! values = + Sources.values [| 1; 2; 3 |] + |> Observable.toList testContext.CancellationToken + Assert.AreEqual([ 1; 2; 3 ], values, "toList must collect every element in the order of the source") + } + + [] + member _.``toList returns an empty list for an empty source`` () : Task = task { + let! values = + (Observable.empty () : Observable) + |> Observable.toList testContext.CancellationToken + Assert.IsEmpty (values, "An empty source must give an empty list") + } + + [] + member _.``toList faults with a failure that follows values instead of returning them`` () : Task = task { + let collecting = + Sources.failingAfter [| 1; 2 |] boom + |> Observable.toList testContext.CancellationToken + let! error = + Assert.ThrowsAsync((fun () -> collecting :> Task), "toList must fault when the source fails after values") + Assert.AreSame (boom, error, "toList must fault with the exception of the source itself") + } + + [] + member _.``toLookup groups the elements by key with and without a token`` () : Task = task { + let! withoutToken = Observable.toLookup (Fruits.source (), Fruits.initial) + let! withToken = Observable.toLookup (Fruits.source (), Fruits.initial, testContext.CancellationToken) + for struct (overload, lookup) in [ struct ("without a token", withoutToken); struct ("with a token", withToken) ] do + // Without a comparer the keys are compared by the default comparer, which is case-sensitive for strings + Assert.HasCount (5, lookup, $"Every initial, in either case, must form its own group %s{overload}") + CollectionAssert.AreEqual ([| "apple" |], Seq.toArray lookup["a"], $"The lower case initial must group its own element %s{overload}") + CollectionAssert.AreEqual ([| "Avocado" |], Seq.toArray lookup["A"], $"The upper case initial must group its own element %s{overload}") + } + + [] + member _.``toLookup with a positional key comparer groups the keys that the comparer finds equal with and without a token`` () : Task = task { + // The initials are meant to be case-insensitive here, so the comparer ignores case on purpose + let! withoutToken = + Observable.toLookup (Fruits.source (), Fruits.initial, StringComparer.OrdinalIgnoreCase) + let! withToken = + Observable.toLookup (Fruits.source (), Fruits.initial, StringComparer.OrdinalIgnoreCase, testContext.CancellationToken) + for struct (overload, lookup) in [ struct ("without a token", withoutToken); struct ("with a token", withToken) ] do + Assert.HasCount (3, lookup, $"Initials that differ only in case must share a group %s{overload}") + CollectionAssert.AreEqual ( + [| "apple"; "Avocado" |], + Seq.toArray lookup["A"], + $"The group of an initial must hold the elements of both cases in source order %s{overload}" + ) + CollectionAssert.AreEqual ( + [| "banana"; "Blueberry" |], + Seq.toArray lookup["b"], + $"The group must be found with either case of the initial %s{overload}" + ) + } + + [] + member _.``toLookup with a positional element selector groups the projected elements with and without a token`` () : Task = task { + let! withoutToken = Observable.toLookup (Fruits.source (), Fruits.initial, Fruits.nameLength) + let! withToken = + Observable.toLookup (Fruits.source (), Fruits.initial, Fruits.nameLength, testContext.CancellationToken) + // The element type of the lookups proves that the third argument was taken as the element selector + let lookups : struct (string * ILookup) list = [ struct ("without a token", withoutToken); struct ("with a token", withToken) ] + for struct (overload, lookup) in lookups do + Assert.HasCount (5, lookup, $"Without a comparer every initial, in either case, must form its own group %s{overload}") + CollectionAssert.AreEqual ([| 6 |], Seq.toArray lookup["b"], $"The group must hold the projected element %s{overload}") + CollectionAssert.AreEqual ([| 9 |], Seq.toArray lookup["B"], $"The group must hold the projected element %s{overload}") + } + + [] + member _.``toLookup with an element selector and a key comparer groups the projected elements of equal keys with and without a token`` () : Task = + task { + // The initials are meant to be case-insensitive here, so the comparer ignores case on purpose + let! withoutToken = + Observable.toLookup (Fruits.source (), Fruits.initial, Fruits.nameLength, StringComparer.OrdinalIgnoreCase) + let! withToken = + Observable.toLookup ( + Fruits.source (), + Fruits.initial, + Fruits.nameLength, + StringComparer.OrdinalIgnoreCase, + testContext.CancellationToken + ) + for struct (overload, lookup) in [ struct ("without a token", withoutToken); struct ("with a token", withToken) ] do + Assert.HasCount (3, lookup, $"Initials that differ only in case must share a group %s{overload}") + CollectionAssert.AreEqual ( + [| 5; 7 |], + Seq.toArray lookup["a"], + $"The group must hold the projected elements in source order %s{overload}" + ) + CollectionAssert.AreEqual ( + [| 6; 9 |], + Seq.toArray lookup["B"], + $"The group must hold the projected elements in source order %s{overload}" + ) + } + + [] + member _.``toLookup returns an empty lookup for an empty source`` () : Task = task { + let! lookup = + Observable.toLookup ((Observable.empty () : Observable), Fruits.initial, testContext.CancellationToken) + // R3's lookup answers a key it does not contain with an empty sequence rather than with an exception + Assert.IsEmpty (lookup, "An empty source must give a lookup without groups") + Assert.IsEmpty (lookup["a"], "A missing key must give an empty sequence") + Assert.IsFalse (lookup.Contains "a", "An empty lookup must not contain any key") + } + + [] + member _.``Task terminal functions fault with a terminal failure of the source as the same exception`` (functionName : string) : Task = task { + let terminal = + Terminals.runTask testContext.CancellationToken functionName (Sources.failingAfter [||] boom) + // The failure arrives inside Subscribe, and R3 faults the task with it at once + Assert.IsTrue (terminal.IsFaulted, $"%s{functionName} must be faulted by the time it returns") + let! error = + Assert.ThrowsAsync((fun () -> terminal), $"Awaiting %s{functionName} must throw the failure of the source") + Assert.AreSame (boom, error, $"%s{functionName} must fault with the exception of the source itself") + } + + [] + member _.``Task terminal functions fault with an error resumed by a hot source and unsubscribe from it`` (functionName : string) : Task = task { + use subject = new Subject () + let probe = SubscriptionProbe () + let terminal = + subject + |> probe.Watch + |> Terminals.runTask testContext.CancellationToken functionName + Assert.IsFalse (terminal.IsCompleted, $"%s{functionName} must wait for the source") + // R3's terminal observers treat an error reported through OnErrorResume as fatal: they fault the task and dispose the subscription + subject.OnErrorResume boom + Assert.AreEqual (1, probe.Disposed, $"%s{functionName} must dispose the subscription to the source when the error arrives") + let! error = + Assert.ThrowsAsync((fun () -> terminal), $"Awaiting %s{functionName} must throw the error") + Assert.AreSame (boom, error, $"%s{functionName} must fault with the resumed exception itself") + } + + [] + member _.``Task terminal functions are cancelled and unsubscribe from a hot source when their token is cancelled`` + (functionName : string) + : Task + = task { + use subject = new Subject () + let probe = SubscriptionProbe () + use cancellation = CancellationTokenSource.CreateLinkedTokenSource testContext.CancellationToken + let terminal = + subject + |> probe.Watch + |> Terminals.runTask cancellation.Token functionName + Assert.IsFalse (terminal.IsCompleted, $"%s{functionName} must wait for the source") + // R3 runs its cancellation callback inside Cancel, and the callback disposes the subscription before it cancels the task. + // The status is checked only after awaiting, because toList cancels its own task in a continuation + cancellation.Cancel () + Assert.AreEqual (1, probe.Disposed, $"Cancel must dispose the subscription of %s{functionName} to the source before it returns") + let! error = + Assert.ThrowsAsync((fun () -> terminal), $"Awaiting a cancelled %s{functionName} must throw") + Assert.AreEqual (cancellation.Token, error.CancellationToken, $"The exception must carry the token that cancelled %s{functionName}") + Assert.AreEqual (TaskStatus.Canceled, terminal.Status, $"%s{functionName} must be cancelled rather than faulted") + } + + [] + member _.``Task terminal functions with an already cancelled token are cancelled when they return and keep no subscription`` + (functionName : string) + : Task + = task { + // A hot source, because only a source that never completes by itself lets the probe prove that the subscription was disposed + use subject = new Subject () + let probe = SubscriptionProbe () + use cancellation = new CancellationTokenSource () + cancellation.Cancel () + let terminal = + subject + |> probe.Watch + |> Terminals.runTask cancellation.Token functionName + // R3 registers on the token before it subscribes, so the callback disposes the observer and cancels the task at once; + // the subscription that R3 still makes is disposed as soon as it is assigned to the disposed observer + Assert.AreEqual (TaskStatus.Canceled, terminal.Status, $"%s{functionName} must be cancelled by the time it returns") + Assert.AreEqual (0, probe.Active, $"%s{functionName} must not keep a subscription to the source") + if functionName = "iterAsync" then + // Unlike the R3 terminals, which subscribe and dispose at once, iterAsync does not subscribe at all + Assert.AreEqual (0, probe.Subscribed, "iterAsync must not subscribe to the source with an already cancelled token") + let! error = + Assert.ThrowsAsync((fun () -> terminal), $"Awaiting a cancelled %s{functionName} must throw") + Assert.AreEqual (cancellation.Token, error.CancellationToken, $"The exception must carry the token that cancelled %s{functionName}") + } diff --git a/tests/FSharp.Control.R3.Tests/TestHelpers.fs b/tests/FSharp.Control.R3.Tests/TestHelpers.fs new file mode 100644 index 0000000..eeccabb --- /dev/null +++ b/tests/FSharp.Control.R3.Tests/TestHelpers.fs @@ -0,0 +1,493 @@ +module FSharp.Control.R3.Tests.TestHelpers + +open System +open System.Collections.Generic +open System.Threading +open System.Threading.Tasks +open Microsoft.VisualStudio.TestTools.UnitTesting +open R3 +open FSharp.Control.R3 + +// The helpers below reach both flavours through abbreviations, so that no test file has to open both flavour modules, +// whose functions shadow each other. Module abbreviations stay local to this file even where it is opened, so a test file +// that calls both flavours directly declares its own +module TaskObservable = FSharp.Control.R3.Task.Observable +module AsyncObservable = FSharp.Control.R3.Async.Observable +type private TaskConversions = FSharp.Control.R3.Task.Extensions.Observable +type private AsyncConversions = FSharp.Control.R3.Async.Extensions.Observable + +/// Deterministic sources: every one of them emits synchronously on the subscribing thread. +[] +module Sources = + + /// Emits the items, then completes successfully. + let values (items : 'T array) : Observable<'T> = Observable.ToObservable items + + /// Emits the items, then completes with the failure. + let failingAfter (items : 'T array) (error : exn) : Observable<'T> = + ObservableExtensions.Concat (Observable.ToObservable items, Observable.Throw<'T> error) + + /// + /// Emits 1, reports the error through , emits 2, + /// then completes successfully. + /// + let resumingError (error : exn) : Observable = + Observable.Create(fun observer -> + observer.OnNext 1 + observer.OnErrorResume error + observer.OnNext 2 + observer.OnCompleted () + Disposable.Empty + ) + +/// +/// Fruits whose initials differ only in case, to test grouping with and without a case-insensitive comparer. +/// +/// Grouped by with the default comparer, +/// they form five groups (a, A, b, B, c). With they form three: +/// apple and Avocado, banana and Blueberry, cherry. +/// +/// +[] +module Fruits = + + /// A synchronous source of the fruits apple, Avocado, banana, Blueberry and cherry, in this order. + let source () = Sources.values [| "apple"; "Avocado"; "banana"; "Blueberry"; "cherry" |] + + // Substring rather than a span: the key leaves the function and is stored in the lookup + /// The grouping key of a fruit, its first letter; every test picks the comparer that decides whether its case matters. + let initial (fruit : string) = fruit.Substring (0, 1) + + /// The element that the element selectors project a fruit to: 5, 7, 6, 9 and 6 in source order. + let nameLength (fruit : string) = fruit.Length + +/// Helpers for running Async computations from task-based tests. +[] +module AsyncTest = + + /// + /// Starts the computation on the calling thread with the token, so a cold Async function subscribes before this returns. + /// + /// Binding an Async in a task expression starts it the same way but with the default token, + /// so use this helper whenever the test needs to cancel the computation. + /// + /// + let start (cancellationToken : CancellationToken) (computation : Async<'T>) = Async.StartImmediateAsTask (computation, cancellationToken) + +/// A counter whose reaching a target can be awaited; it owns its lock, so a wait registered while the counter changes is never missed. +[] +type private CountWaiters () = + let sync = obj () + let mutable count = 0 + let mutable failure : (int -> exn) voption = ValueNone + let mutable waiters : struct (int * TaskCompletionSource) list = [] + + /// The current value of the counter. + member _.Count = lock sync (fun () -> count) + + /// Increments the counter and completes the waits whose target it reached. + member _.Increment () = + let ready = + lock + sync + (fun () -> + count <- count + 1 + let ready, pending = + waiters + |> List.partition (fun struct (target, _) -> count >= target) + waiters <- pending + ready + ) + for struct (_, waiter) in ready do + waiter.TrySetResult () |> ignore + + /// Fails the pending waits, and every later wait for a target not reached yet, with the error for its target. + member _.FailAll (error : int -> exn) = + let pending = + lock + sync + (fun () -> + failure <- ValueSome error + let pending = waiters + waiters <- [] + pending + ) + for struct (target, waiter) in pending do + waiter.TrySetException (error target) |> ignore + + /// Completes once the counter reaches the target. + member _.WaitAsync (cancellationToken : CancellationToken, target : int) : Task = + let waiter = + lock + sync + (fun () -> + if count >= target then + Task.CompletedTask + else + match failure with + | ValueSome error -> Task.FromException (error target) + | ValueNone -> + // RunContinuationsAsynchronously keeps the awaiting test code off the thread that increments the counter, + // so a test continuation never runs inside the lock of an operator + let waiter = TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously) + waiters <- struct (target, waiter) :: waiters + waiter.Task + ) + waiter.WaitAsync cancellationToken + +/// +/// An observer that records every notification of the source it is attached to. +/// +/// Unlike it also records the errors reported through +/// , which LiveList forwards to the process-wide unhandled +/// exception handler, and it lets a test wait for values or completion that arrive on another thread. +/// +/// +/// Like every R3 it ignores every notification once it is disposed or has completed, +/// so it cannot show that an operator stopped forwarding: prove that with a +/// read while the watched source is still open, +/// or with the cancellation tokens. +/// +/// +[] +type Recorder<'T> () = + inherit Observer<'T> () + + let sync = obj () + let values = ResizeArray<'T>() + let errors = ResizeArray() + // RunContinuationsAsynchronously keeps the awaiting test code off the thread that delivers the notification, + // so a test continuation never runs inside the lock of an operator + let completion = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let mutable result = ValueNone + let arrivals = CountWaiters () + + let notEnoughValues count : exn = + InvalidOperationException $"The source completed before emitting %d{count} values" + + override _.OnNextCore value = + lock sync (fun () -> values.Add value) + arrivals.Increment () + + override _.OnErrorResumeCore error = lock sync (fun () -> errors.Add error) + + override _.OnCompletedCore completed = + lock sync (fun () -> result <- ValueSome completed) + // A wait for values that can no longer arrive fails at once instead of hanging until the test timeout + arrivals.FailAll notEnoughValues + completion.TrySetResult completed |> ignore + + /// Snapshot of the received values in arrival order. + member _.Values = lock sync (fun () -> values.ToArray ()) + + /// Snapshot of the errors received through in arrival order. + member _.Errors = lock sync (fun () -> errors.ToArray ()) + + /// The completion result, or ValueNone while the source has not completed. + member _.Completion = lock sync (fun () -> result) + + /// Whether the source has completed successfully. + member this.IsCompletedSuccessfully = this.Completion |> ValueOption.exists _.IsSuccess + + /// The exception of a failed completion, or ValueNone when the source has not completed or completed successfully. + member this.Failure = + this.Completion + |> ValueOption.bind (fun completed -> + if completed.IsFailure then + ValueSome (nonNull completed.Exception) + else + ValueNone + ) + + /// Completes once at least count values have arrived; fails if the source completes before that. + member _.WaitForValuesAsync (cancellationToken : CancellationToken, count : int) : Task = arrivals.WaitAsync (cancellationToken, count) + + /// Completes with the completion result of the source. + member _.WaitForCompletionAsync (cancellationToken : CancellationToken) : Task = completion.Task.WaitAsync cancellationToken + + /// Subscribes a new recorder to the source; disposing the recorder disposes the subscription. + static member Attach (source : Observable<'T>) = + let recorder = new Recorder<'T> () + source.Subscribe recorder |> ignore + recorder + +/// +/// Asserts that the recorded sequence failed with this very exception, not with a wrapper or an equal copy. +/// +/// Exceptions do not override , so comparing the voptions compares the +/// exceptions by reference, and on a mismatch MSTest reports the actual failure, or that the sequence has not failed. +/// +/// +let assertFailedWith (expected : exn) (recorder : Recorder<'T>) (message : string) = Assert.AreEqual (ValueSome expected, recorder.Failure, message) + +/// Counts subscriptions to and disposals of a source, to prove that an operator subscribed or unsubscribed. +[] +type SubscriptionProbe () = + let mutable subscribed = 0 + let disposals = CountWaiters () + + /// Number of subscriptions made to the watched source. + member _.Subscribed = Volatile.Read &subscribed + + /// Number of subscriptions to the watched source that were disposed. + member _.Disposed = disposals.Count + + /// Number of subscriptions to the watched source that are still alive. + member this.Active = this.Subscribed - this.Disposed + + /// + /// Wraps the source so that its subscriptions and disposals are counted. + /// + /// R3 also disposes the wrapper when the watched source completes by itself, + /// so a disposal only proves an unsubscription when the watched source never completes, such as an open subject. + /// + /// + member _.Watch (source : Observable<'T>) = + source.Do (onSubscribe = (fun () -> Interlocked.Increment &subscribed |> ignore), onDispose = (fun () -> disposals.Increment ())) + + /// + /// Completes once at least count subscriptions to the watched source have been disposed. + /// + /// R3 completes the task of a terminal operator before it disposes the subscription, and code awaiting the task may resume + /// in between, so a test awaits this before it asserts that a terminal function unsubscribed after awaiting it. + /// + /// + member _.WaitForDisposedAsync (cancellationToken : CancellationToken, count : int) : Task = disposals.WaitAsync (cancellationToken, count) + +/// +/// An asynchronous selector whose invocations are recorded and whose results the test releases, +/// so the interleaving of asynchronous work is decided by the test and not by timing. +/// +/// Gates are keyed by value: invocations with equal values share one gate, so tests use distinct values. +/// Releasing a value before its invocation starts makes that invocation finish at once. +/// +/// +/// Releasing never resumes the invocation on the releasing thread, so a test always waits with +/// +/// or +/// before it asserts on the outcome of a release. +/// +/// +/// The task flavour deliberately does not observe the selector token: in R3 an +/// stops the worker loop of the sequential, sequential parallel +/// and throttle-first-last modes for good, without completing, which would turn a failed assertion into a hang. +/// Tests inspect instead. +/// +/// +[] +type GatedSelector<'T, 'R when 'T : equality and 'T : not null> (project : 'T -> 'R) = + let sync = obj () + let gates = Dictionary<'T, TaskCompletionSource>(HashIdentity.Structural) + let started = ResizeArray() + let mutable inFlight = 0 + let mutable maxInFlight = 0 + let starts = CountWaiters () + + let gateFor value = + lock + sync + (fun () -> + match gates.TryGetValue value with + | true, gate -> gate + | false, _ -> + let gate = TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously) + gates.Add (value, gate) + gate + ) + + let enter (cancellationToken : CancellationToken) value = + lock + sync + (fun () -> + started.Add (struct (value, cancellationToken)) + inFlight <- inFlight + 1 + maxInFlight <- max maxInFlight inFlight + ) + starts.Increment () + + let waitAsync (cancellationToken : CancellationToken) value : Task = task { + enter cancellationToken value + try + do! (gateFor value).Task + finally + lock sync (fun () -> inFlight <- inFlight - 1) + } + + /// Lets the invocation for the value finish. + member _.Release value = (gateFor value).TrySetResult() |> ignore + + /// Values the selector was invoked with, in invocation order. + member _.Started = + lock + sync + (fun () -> + started + |> Seq.map (fun struct (value, _) -> value) + |> Seq.toArray + ) + + /// Tokens the selector was invoked with, in invocation order. + member _.Tokens = + lock + sync + (fun () -> + started + |> Seq.map (fun struct (_, token) -> token) + |> Seq.toArray + ) + + /// Highest number of invocations that were running at the same time. + member _.MaxInFlight = lock sync (fun () -> maxInFlight) + + /// Completes once the selector has been invoked at least count times. + member _.WaitForStartedAsync (cancellationToken : CancellationToken, count : int) : Task = starts.WaitAsync (cancellationToken, count) + + /// + /// The selector in the shape of . + /// + member _.InvokeTask (cancellationToken : CancellationToken) (value : 'T) : Task<'R> = task { + do! waitAsync cancellationToken value + return project value + } + + /// + /// The selector in the shape of ; + /// it records the token of the computation. + /// + member _.InvokeAsync (value : 'T) : Async<'R> = async { + let! cancellationToken = Async.CancellationToken + do! waitAsync cancellationToken value |> Async.AwaitTask + // Projected after the gate rather than inside it, so an exception of the projection is raised by the computation itself + return project value + } + +/// +/// Asserts that applying the function to a source throws at once, +/// before its result could be subscribed, and that the exception names the parameter and carries the rejected value. +/// +let assertArgumentRejected (apply : Observable -> 'Result) (paramName : string) (rejected : int) (message : string) = + use subject = new Subject () + let error = + Assert.Throws(Action (fun () -> subject |> apply |> ignore), message) + Assert.AreEqual (paramName, error.ParamName, $"%s{message}: the exception must name the parameter") + Assert.AreEqual (box rejected, error.ActualValue, $"%s{message}: the exception must carry the rejected value") + +/// MSTest dynamic data: one row per name, with the name as its only argument. +// :> obj rather than box: box returns objnull, which raises FS3261 under enable +let dataRows (names : string seq) : obj array seq = names |> Seq.map (fun name -> [| name :> obj |]) + +/// Dispatches to the flavour named by a data row, "Task" or "Async", of the functions that both flavours provide. +[] +module Flavour = + + /// Fails a test whose data row names neither flavour. + let unknown (flavour : string) : 'Result = invalidArg (nameof flavour) $"Unknown flavour %s{flavour}" + + /// Projects the source with the mapAsync function of the flavour, which invokes the selector of that flavour. + let mapAsyncWith + (flavour : string) + (options : ProcessingOptions) + (taskSelector : CancellationToken -> 'T -> Task<'R>) + (asyncSelector : 'T -> Async<'R>) + (source : Observable<'T>) + : Observable<'R> = + match flavour with + | "Task" -> source |> TaskObservable.mapAsync options taskSelector + | "Async" -> source |> AsyncObservable.mapAsync options asyncSelector + | _ -> unknown flavour + + /// Projects the source with the mapAsync function of the flavour, which invokes the gated selector. + let mapWith (flavour : string) (options : ProcessingOptions) (selector : GatedSelector<'T, 'R>) (source : Observable<'T>) : Observable<'R> = + mapAsyncWith flavour options selector.InvokeTask selector.InvokeAsync source + +/// Terminal functions run by name with fixed arguments and their results discarded, for the tests that cover every one of them. +[] +module Terminals = + + /// The terminal functions that both flavours provide. + let names = [| + "aggregate" + "all" + "existsAsync" + "firstAsync" + "iter" + "length" + "toArray" + "toList" + "toLookup" + |] + + // all checks x > 0, so it decides early only at an element that is not positive: the tests that feed positive elements + // rely on it waiting for the completion like aggregate, iter, length, toArray, toList and toLookup + + /// Starts the Task terminal function; iterAsync is accepted as well. + let runTask (cancellationToken : CancellationToken) (name : string) (source : Observable) : Task = + match name with + | "aggregate" -> TaskObservable.aggregate cancellationToken 0 (+) source + | "all" -> TaskObservable.all cancellationToken (fun x -> x > 0) source + | "existsAsync" -> TaskObservable.existsAsync cancellationToken source + | "firstAsync" -> TaskObservable.firstAsync cancellationToken source + | "iter" -> TaskObservable.iter cancellationToken ignore source + | "iterAsync" -> TaskObservable.iterAsync cancellationToken ProcessingOptions.Default (fun _ _ -> Task.FromResult ()) source + | "length" -> TaskObservable.length cancellationToken source + | "toArray" -> TaskObservable.toArray cancellationToken source + | "toList" -> TaskObservable.toList cancellationToken source + | "toLookup" -> TaskConversions.toLookup (source, (fun x -> x % 2), cancellationToken) + | other -> invalidArg (nameof name) $"Unknown terminal function %s{other}" + + /// The Async terminal function as a cold computation. + let runAsync (name : string) (source : Observable) : Async = + match name with + | "aggregate" -> source |> AsyncObservable.aggregate 0 (+) |> Async.Ignore + | "all" -> + source + |> AsyncObservable.all (fun x -> x > 0) + |> Async.Ignore + | "existsAsync" -> source |> AsyncObservable.existsAsync |> Async.Ignore + | "firstAsync" -> source |> AsyncObservable.firstAsync |> Async.Ignore + | "iter" -> source |> AsyncObservable.iter ignore + | "length" -> source |> AsyncObservable.length |> Async.Ignore + | "toArray" -> source |> AsyncObservable.toArray |> Async.Ignore + | "toList" -> source |> AsyncObservable.toList |> Async.Ignore + | "toLookup" -> + AsyncConversions.toLookup (source, fun x -> x % 2) + |> Async.Ignore + | other -> invalidArg (nameof name) $"Unknown terminal function %s{other}" + + /// Starts the terminal function of the flavour; the Async flavour runs with the token as the token of its computation. + let runWith (cancellationToken : CancellationToken) (flavour : string) (name : string) (source : Observable) : Task = + match flavour with + | "Task" -> runTask cancellationToken name source + | "Async" -> AsyncTest.start cancellationToken (runAsync name source) + | _ -> Flavour.unknown flavour + +/// +/// A synchronization context that counts the callbacks posted to it and runs them on the thread pool, +/// to observe whether a continuation resumed on the captured context. +/// +[] +type RecordingSynchronizationContext () = + inherit SynchronizationContext () + + let mutable posts = 0 + + /// Number of callbacks posted to this context. + member _.Posts = Volatile.Read &posts + + /// + override _.Post (callback, state) = + Interlocked.Increment &posts |> ignore + ThreadPool.QueueUserWorkItem (fun _ -> callback.Invoke state) + |> ignore + + /// + /// Runs the action with this context installed as the current one and restores the previous context afterwards. + /// The action must not await, because the current synchronization context belongs to the thread. + /// + member this.Run (action : unit -> 'Result) = + let previous = SynchronizationContext.Current + SynchronizationContext.SetSynchronizationContext this + try + action () + finally + SynchronizationContext.SetSynchronizationContext previous diff --git a/tests/FSharp.Control.R3.Tests/testconfig.json b/tests/FSharp.Control.R3.Tests/testconfig.json index ba207a0..2ff5ba1 100644 --- a/tests/FSharp.Control.R3.Tests/testconfig.json +++ b/tests/FSharp.Control.R3.Tests/testconfig.json @@ -4,6 +4,9 @@ "enabled": true, "scope": "method", "workers": 0 + }, + "timeout": { + "test": 10000 } } }