diff --git a/release-notes.txt b/release-notes.txt index f0f7386..5debb47 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -2,6 +2,7 @@ Release notes: Unreleased + - test: add SideEffects module to TaskSeq.ReplicateInfinite.Tests.fs and TaskSeq.ThreadState.Tests.fs, verifying computation/generator/folder call counts and re-enumeration semantics - tests: add coverage for side-effect re-execution semantics when re-enumerating a `taskSeq` with independent `CancellationToken`s - test: add TaskSeq.Issue452.Tests.fs, regression tests wrapping an externally-produced IAsyncEnumerable<'T> (TaskSeq.map and `taskSeq { for .. in .. do yield .. }`) while running on a custom, single-threaded TaskScheduler; investigates #452's reported duplicated-final-item bug, which could not be reproduced outside of Orleans, see #452 - adds TaskSeq.tryMax and TaskSeq.tryMin: safe variants of TaskSeq.max and TaskSeq.min that return None instead of raising ArgumentException when the input sequence is empty diff --git a/src/FSharp.Control.TaskSeq.Test/TaskSeq.ReplicateInfinite.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.ReplicateInfinite.Tests.fs index 34e40fe..6312346 100644 --- a/src/FSharp.Control.TaskSeq.Test/TaskSeq.ReplicateInfinite.Tests.fs +++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.ReplicateInfinite.Tests.fs @@ -156,3 +156,71 @@ module ReplicateUntilNoneAsync = arr[0] |> should equal 1 arr[count - 1] |> should equal count } + + +module SideEffects = + [] + let ``TaskSeq-replicateInfiniteAsync re-runs the computation on each fresh enumeration`` () = task { + let mutable calls = 0 + + let comp () = task { + calls <- calls + 1 + return calls + } + + let ts = TaskSeq.replicateInfiniteAsync comp + + let! arr1 = ts |> TaskSeq.take 3 |> TaskSeq.toArrayAsync + arr1 |> should equal [| 1; 2; 3 |] + calls |> should equal 3 + + // a fresh enumeration starts the computation from scratch; side effects + // (here, the call counter) keep accumulating across enumerations + let! arr2 = ts |> TaskSeq.take 2 |> TaskSeq.toArrayAsync + arr2 |> should equal [| 4; 5 |] + calls |> should equal 5 + } + + [] + let ``TaskSeq-replicateUntilNoneAsync re-runs the computation from its initial state on each fresh enumeration`` () = task { + let mutable totalCalls = 0 + + let comp () = task { + let mutable n = 0 + totalCalls <- totalCalls + 1 + + if n <= 1 then + n <- n + 1 + return Some n + else + return None + } + + let ts = TaskSeq.replicateUntilNoneAsync comp + + let! arr1 = ts |> TaskSeq.toArrayAsync + arr1 |> should equal [| 1 |] + totalCalls |> should equal 2 + + // re-enumerating re-invokes the generator function itself (state is local + // to each call), so side effects on shared state accumulate further + let! arr2 = ts |> TaskSeq.toArrayAsync + arr2 |> should equal [| 1 |] + totalCalls |> should equal 4 + } + + [] + let ``TaskSeq-replicateInfinite abandoning enumeration early does not affect a later fresh enumeration`` () = task { + let ts = TaskSeq.replicateInfinite 3 + + // partially enumerate and abandon (dispose) without reaching a natural end + use enum1 = ts.GetAsyncEnumerator System.Threading.CancellationToken.None + let! hasNext = enum1.MoveNextAsync() + hasNext |> should be True + enum1.Current |> should equal 3 + do! enum1.DisposeAsync() + + // a fresh enumerator over the same taskSeq starts cleanly from the beginning + let! arr = ts |> TaskSeq.take 3 |> TaskSeq.toArrayAsync + arr |> should equal [| 3; 3; 3 |] + } diff --git a/src/FSharp.Control.TaskSeq.Test/TaskSeq.ThreadState.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.ThreadState.Tests.fs index c62189f..d37718d 100644 --- a/src/FSharp.Control.TaskSeq.Test/TaskSeq.ThreadState.Tests.fs +++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.ThreadState.Tests.fs @@ -147,3 +147,71 @@ module Functionality = viaThread |> should equal viaScan } + + +module SideEffects = + [] + let ``TaskSeq-threadState folder is invoked exactly once per source item`` () = task { + let mutable folderCalls = 0 + + let source = taskSeq { + for i in 1..5 do + yield i + } + + let folder state x = + folderCalls <- folderCalls + 1 + x + state, state + 1 + + let! result = TaskSeq.threadState folder 0 source |> TaskSeq.toArrayAsync + result |> should haveLength 5 + folderCalls |> should equal 5 + } + + [] + let ``TaskSeq-threadState re-enumerating the result re-runs source side effects and folder calls`` () = task { + let mutable itemsProduced = 0 + let mutable folderCalls = 0 + + let source = taskSeq { + for i in 1..3 do + itemsProduced <- itemsProduced + 1 + yield i + } + + let folder state x = + folderCalls <- folderCalls + 1 + x + state, state + 1 + + let ts = TaskSeq.threadState folder 0 source + + let! result1 = ts |> TaskSeq.toArrayAsync + result1 |> should equal [| 1; 3; 5 |] + itemsProduced |> should equal 3 + folderCalls |> should equal 3 + + // threadState produces a fresh taskSeq that re-drives the source and the + // folder from scratch on each independent enumeration + let! result2 = ts |> TaskSeq.toArrayAsync + result2 |> should equal result1 + itemsProduced |> should equal 6 + folderCalls |> should equal 6 + } + + [] + let ``TaskSeq-threadStateAsync folder is invoked exactly once per source item`` () = task { + let mutable folderCalls = 0 + let source = taskSeq { yield! [ 1..4 ] } + + let folder state x = task { + folderCalls <- folderCalls + 1 + return x + state, state + 1 + } + + let! result = + TaskSeq.threadStateAsync folder 0 source + |> TaskSeq.toArrayAsync + + result |> should haveLength 4 + folderCalls |> should equal 4 + }