From 6e97babe7472f1bef7019e2db3d5131e701b8db5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 14:31:33 +0000 Subject: [PATCH 1/2] feat: add TaskSeq.tryMax and TaskSeq.tryMin Safe option-returning variants of TaskSeq.max/min that return None for empty sequences instead of raising ArgumentException. Follows established 'try' patterns (tryHead, tryLast, tryExactlyOne, etc.) and mirrors F# 8's Seq.tryMax/Seq.tryMin. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- release-notes.txt | 1 + .../TaskSeq.MaxMin.Tests.fs | 86 +++++++++++++++++++ src/FSharp.Control.TaskSeq/TaskSeq.fs | 2 + src/FSharp.Control.TaskSeq/TaskSeq.fsi | 20 +++++ src/FSharp.Control.TaskSeq/TaskSeqInternal.fs | 18 ++++ 5 files changed, 127 insertions(+) diff --git a/release-notes.txt b/release-notes.txt index 4927a8ac..6e15e3f9 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -2,6 +2,7 @@ Release notes: Unreleased + - 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 - test: rename `SideEffect` module to `SideEffects` in TaskSeq.Concat.Tests.fs, TaskSeq.Delay.Tests.fs, and TaskSeq.Item.Tests.fs for consistency with the rest of the test suite (50+ files already use the plural form) - 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 diff --git a/src/FSharp.Control.TaskSeq.Test/TaskSeq.MaxMin.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.MaxMin.Tests.fs index 638c856a..0bf8660f 100644 --- a/src/FSharp.Control.TaskSeq.Test/TaskSeq.MaxMin.Tests.fs +++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.MaxMin.Tests.fs @@ -10,6 +10,8 @@ open FSharp.Control // // TaskSeq.max // TaskSeq.min +// TaskSeq.tryMax +// TaskSeq.tryMin // TaskSeq.maxBy // TaskSeq.minBy // TaskSeq.maxByAsync @@ -316,3 +318,87 @@ module SideEffects = do! test (MinMax.getByFunction minMax) 20 do! test (MinMax.getByFunction minMax) 30 } + + +module TryMaxMin = + [] + let ``TaskSeq-tryMax returns None for null source`` () = + assertNullArg + <| fun () -> TaskSeq.tryMax (null: TaskSeq) + + [] + let ``TaskSeq-tryMin returns None for null source`` () = + assertNullArg + <| fun () -> TaskSeq.tryMin (null: TaskSeq) + + [)>] + let ``TaskSeq-tryMax returns None on empty`` variant = task { + let! result = Gen.getEmptyVariant variant |> TaskSeq.tryMax + result |> should equal None + } + + [)>] + let ``TaskSeq-tryMin returns None on empty`` variant = task { + let! result = Gen.getEmptyVariant variant |> TaskSeq.tryMin + result |> should equal None + } + + [] + let ``TaskSeq-tryMax returns Some for singleton`` () = task { + let! result = TaskSeq.singleton 42 |> TaskSeq.tryMax + result |> should equal (Some 42) + } + + [] + let ``TaskSeq-tryMin returns Some for singleton`` () = task { + let! result = TaskSeq.singleton 42 |> TaskSeq.tryMin + result |> should equal (Some 42) + } + + [)>] + let ``TaskSeq-tryMax returns Some max of sequence`` variant = task { + let! result = Gen.getSeqImmutable variant |> TaskSeq.tryMax + result |> should equal (Some 10) + } + + [)>] + let ``TaskSeq-tryMin returns Some min of sequence`` variant = task { + let! result = Gen.getSeqImmutable variant |> TaskSeq.tryMin + result |> should equal (Some 1) + } + + [] + let ``TaskSeq-tryMax and max agree on non-empty sequence`` () = task { + let ts = TaskSeq.ofList [ 3; 1; 4; 1; 5; 9; 2; 6 ] + let! viaMax = ts |> TaskSeq.max + let ts2 = TaskSeq.ofList [ 3; 1; 4; 1; 5; 9; 2; 6 ] + let! viaTryMax = ts2 |> TaskSeq.tryMax + viaTryMax |> should equal (Some viaMax) + } + + [] + let ``TaskSeq-tryMin and min agree on non-empty sequence`` () = task { + let ts = TaskSeq.ofList [ 3; 1; 4; 1; 5; 9; 2; 6 ] + let! viaMin = ts |> TaskSeq.min + let ts2 = TaskSeq.ofList [ 3; 1; 4; 1; 5; 9; 2; 6 ] + let! viaTryMin = ts2 |> TaskSeq.tryMin + viaTryMin |> should equal (Some viaMin) + } + + [)>] + let ``TaskSeq-tryMax re-iteration reflects side effects`` variant = task { + let ts = Gen.getSeqWithSideEffect variant + let! first = ts |> TaskSeq.tryMax + let! second = ts |> TaskSeq.tryMax + first |> should equal (Some 10) + second |> should equal (Some 20) + } + + [)>] + let ``TaskSeq-tryMin re-iteration reflects side effects`` variant = task { + let ts = Gen.getSeqWithSideEffect variant + let! first = ts |> TaskSeq.tryMin + let! second = ts |> TaskSeq.tryMin + first |> should equal (Some 1) + second |> should equal (Some 11) + } diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fs b/src/FSharp.Control.TaskSeq/TaskSeq.fs index a19b1b88..dab056d7 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fs @@ -303,6 +303,8 @@ type TaskSeq private () = static member max source = Internal.maxMin max source static member min source = Internal.maxMin min source + static member tryMax source = Internal.tryMaxMin max source + static member tryMin source = Internal.tryMaxMin min source static member maxBy projection source = Internal.maxMinBy (<) projection source // looks like 'less than', is 'greater than' static member minBy projection source = Internal.maxMinBy (>) projection source static member maxByAsync projection source = Internal.maxMinByAsync (<) projection source // looks like 'less than', is 'greater than' diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fsi b/src/FSharp.Control.TaskSeq/TaskSeq.fsi index 0c33683f..a6dc5ce7 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fsi +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fsi @@ -224,6 +224,26 @@ type TaskSeq = /// Thrown when the input task sequence is empty. static member min: source: TaskSeq<'T> -> Task<'T> when 'T: comparison + /// + /// Returns Some with the greatest of all elements of the task sequence, compared via , + /// or None if the sequence is empty. For sequences that should never be empty, prefer . + /// + /// + /// The input task sequence. + /// The largest element of the sequence wrapped in Some, or None if the sequence is empty. + /// Thrown when the input task sequence is null. + static member tryMax: source: TaskSeq<'T> -> Task<'T option> when 'T: comparison + + /// + /// Returns Some with the smallest of all elements of the task sequence, compared via , + /// or None if the sequence is empty. For sequences that should never be empty, prefer . + /// + /// + /// The input task sequence. + /// The smallest element of the sequence wrapped in Some, or None if the sequence is empty. + /// Thrown when the input task sequence is null. + static member tryMin: source: TaskSeq<'T> -> Task<'T option> when 'T: comparison + /// /// Returns the greatest of all elements of the task sequence, compared via /// on the result of applying the function to each element. diff --git a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs index 0a9e8626..60821af8 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs @@ -238,6 +238,24 @@ module internal TaskSeqInternal = return acc } + let inline tryMaxMin ([] maxOrMin) (source: TaskSeq<_>) = + checkNonNull (nameof source) source + + task { + use e = source.GetAsyncEnumerator CancellationToken.None + let! hasFirst = e.MoveNextAsync() + + if not hasFirst then + return None + else + let mutable acc = e.Current + + while! e.MoveNextAsync() do + acc <- maxOrMin e.Current acc + + return Some acc + } + // 'compare' is either `<` or `>` (i.e, less-than, greater-than resp.) let inline maxMinBy ([] compare) ([] projection) (source: TaskSeq<_>) = checkNonNull (nameof source) source From 7422573086d618c2ab24b95ab631b154a0414da5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 8 May 2026 14:31:37 +0000 Subject: [PATCH 2/2] ci: trigger checks