diff --git a/release-notes.txt b/release-notes.txt index 7fbac125..4bbad5a0 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -2,6 +2,7 @@ Release notes: Unreleased + - test: add SideEffects module to TaskSeq.Using.Tests.fs; 7 new tests verify Dispose/DisposeAsync call counts, re-iteration semantics, and early-termination disposal for use and use! CE bindings - perf: pairwise, distinctUntilChanged, distinctUntilChangedWith, distinctUntilChangedWithAsync now use explicit enumerator + while! instead of ValueOption tracking + for-in loop, eliminating per-element struct match overhead - test: add SideEffects module to TaskSeq.Unfold.Tests.fs, verifying generator call counts, re-iteration behaviour, early-termination via take, and exception propagation - test: add SideEffects module to TaskSeq.OfXXX.Tests.fs documenting re-iteration semantics (ofSeq re-evaluates source, ofTaskArray re-awaits cached tasks) diff --git a/src/FSharp.Control.TaskSeq.Test/TaskSeq.Using.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.Using.Tests.fs index 557381e0..87a83ff6 100644 --- a/src/FSharp.Control.TaskSeq.Test/TaskSeq.Using.Tests.fs +++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.Using.Tests.fs @@ -32,6 +32,18 @@ type private MultiDispose(disposed: int ref) = interface IAsyncDisposable with member _.DisposeAsync() = ValueTask(task { do disposed.Value <- -1 }) +/// Tracks how many times Dispose/DisposeAsync has been called. +type private CountingDisposable(disposeCount: int ref) = + interface IDisposable with + member _.Dispose() = disposeCount.Value <- disposeCount.Value + 1 + +/// Tracks how many times DisposeAsync has been called. +type private CountingAsyncDisposable(disposeCount: int ref) = + interface IAsyncDisposable with + member _.DisposeAsync() = + disposeCount.Value <- disposeCount.Value + 1 + ValueTask.CompletedTask + let private check = TaskSeq.length >> Task.map (should equal 1) [] @@ -105,3 +117,109 @@ let ``CE taskSeq: Using! when type implements IDisposable and IAsyncDisposable`` check ts |> Task.map (fun _ -> disposed.Value |> should equal -1) // should prefer IAsyncDisposable, which returns -1 + +module SideEffects = + [] + let ``CE taskSeq: use - Dispose called exactly once per full iteration`` () = task { + let disposeCount = ref 0 + + let ts = taskSeq { + use _ = new CountingDisposable(disposeCount) + yield 1 + } + + do! ts |> TaskSeq.iter ignore + disposeCount.Value |> should equal 1 + } + + [] + let ``CE taskSeq: use - Dispose called on each re-iteration`` () = task { + let disposeCount = ref 0 + + let ts = taskSeq { + use _ = new CountingDisposable(disposeCount) + yield 1 + } + + do! ts |> TaskSeq.iter ignore + do! ts |> TaskSeq.iter ignore + do! ts |> TaskSeq.iter ignore + disposeCount.Value |> should equal 3 + } + + [] + let ``CE taskSeq: use! - DisposeAsync called exactly once per full iteration`` () = task { + let disposeCount = ref 0 + + let ts = taskSeq { + use! _ = task { return new CountingAsyncDisposable(disposeCount) } + yield 1 + } + + do! ts |> TaskSeq.iter ignore + disposeCount.Value |> should equal 1 + } + + [] + let ``CE taskSeq: use! - DisposeAsync called on each re-iteration`` () = task { + let disposeCount = ref 0 + + let ts = taskSeq { + use! _ = task { return new CountingAsyncDisposable(disposeCount) } + yield 1 + } + + do! ts |> TaskSeq.iter ignore + do! ts |> TaskSeq.iter ignore + do! ts |> TaskSeq.iter ignore + disposeCount.Value |> should equal 3 + } + + [] + let ``CE taskSeq: use - Dispose called on early termination via take`` () = task { + let disposeCount = ref 0 + + let ts = taskSeq { + use _ = new CountingDisposable(disposeCount) + yield 1 + yield 2 + yield 3 + } + + // Only take 1 item — enumerator is disposed before the rest of the sequence runs + do! ts |> TaskSeq.take 1 |> TaskSeq.iter ignore + disposeCount.Value |> should equal 1 + } + + [] + let ``CE taskSeq: use - multiple use bindings each get their own Dispose`` () = task { + let disposeCount = ref 0 + + let ts = taskSeq { + use _ = new CountingDisposable(disposeCount) + use _ = new CountingDisposable(disposeCount) + yield 1 + } + + do! ts |> TaskSeq.iter ignore + disposeCount.Value |> should equal 2 + } + + [] + let ``CE taskSeq: use - each re-iteration creates and disposes a fresh resource`` () = task { + let createCount = ref 0 + + let ts = taskSeq { + createCount.Value <- createCount.Value + 1 + use _ = new CountingDisposable(ref 0) // fresh ref each time + yield createCount.Value + } + + let! first = ts |> TaskSeq.toListAsync + let! second = ts |> TaskSeq.toListAsync + + // Each re-iteration re-runs the CE body and creates a new resource + first |> should equal [ 1 ] + second |> should equal [ 2 ] + createCount.Value |> should equal 2 + }