diff --git a/release-notes.txt b/release-notes.txt index 6f7379e..0159cb6 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -2,6 +2,7 @@ Release notes: 1.0.0 + - adds TaskSeq.distinctUntilChangedWith and TaskSeq.distinctUntilChangedWithAsync, #345 - adds TaskSeq.withCancellation, #167 - adds docs/ with fsdocs-based documentation site covering generating, transforming, consuming, combining and advanced operations diff --git a/src/FSharp.Control.TaskSeq.Test/TaskSeq.DistinctUntilChanged.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.DistinctUntilChanged.Tests.fs index 7645924..1192ed3 100644 --- a/src/FSharp.Control.TaskSeq.Test/TaskSeq.DistinctUntilChanged.Tests.fs +++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.DistinctUntilChanged.Tests.fs @@ -6,7 +6,7 @@ open FsUnit.Xunit open FSharp.Control // -// TaskSeq.distinctUntilChanged +// TaskSeq.distinctUntilChanged / distinctUntilChangedWith / distinctUntilChangedWithAsync // @@ -23,6 +23,34 @@ module EmptySeq = |> Task.map (List.isEmpty >> should be True) } + [] + let ``TaskSeq-distinctUntilChangedWith with null source raises`` () = + assertNullArg + <| fun () -> TaskSeq.distinctUntilChangedWith (fun _ _ -> false) null + + [)>] + let ``TaskSeq-distinctUntilChangedWith has no effect on empty`` variant = task { + do! + Gen.getEmptyVariant variant + |> TaskSeq.distinctUntilChangedWith (fun _ _ -> false) + |> TaskSeq.toListAsync + |> Task.map (List.isEmpty >> should be True) + } + + [] + let ``TaskSeq-distinctUntilChangedWithAsync with null source raises`` () = + assertNullArg + <| fun () -> TaskSeq.distinctUntilChangedWithAsync (fun _ _ -> task { return false }) null + + [)>] + let ``TaskSeq-distinctUntilChangedWithAsync has no effect on empty`` variant = task { + do! + Gen.getEmptyVariant variant + |> TaskSeq.distinctUntilChangedWithAsync (fun _ _ -> task { return false }) + |> TaskSeq.toListAsync + |> Task.map (List.isEmpty >> should be True) + } + module Functionality = [] let ``TaskSeq-distinctUntilChanged should return no consecutive duplicates`` () = task { @@ -90,6 +118,129 @@ module Functionality = xs |> should equal [ 1..10 ] } + [] + let ``TaskSeq-distinctUntilChangedWith with structural equality comparer behaves like distinctUntilChanged`` () = task { + let ts = + [ 'A'; 'A'; 'B'; 'Z'; 'C'; 'C'; 'Z'; 'C'; 'D'; 'D'; 'D'; 'Z' ] + |> TaskSeq.ofList + + let! xs = + ts + |> TaskSeq.distinctUntilChangedWith (=) + |> TaskSeq.toListAsync + + xs + |> List.map string + |> String.concat "" + |> should equal "ABZCZCDZ" + } + + [] + let ``TaskSeq-distinctUntilChangedWith with always-true comparer returns only first element`` () = task { + let! xs = + taskSeq { yield! [ 1; 2; 3; 4; 5 ] } + |> TaskSeq.distinctUntilChangedWith (fun _ _ -> true) + |> TaskSeq.toListAsync + + xs |> should equal [ 1 ] + } + + [] + let ``TaskSeq-distinctUntilChangedWith with always-false comparer returns all elements`` () = task { + let! xs = + taskSeq { yield! [ 1; 1; 2; 2; 3 ] } + |> TaskSeq.distinctUntilChangedWith (fun _ _ -> false) + |> TaskSeq.toListAsync + + xs |> should equal [ 1; 1; 2; 2; 3 ] + } + + [] + let ``TaskSeq-distinctUntilChangedWith can use custom projection for equality`` () = task { + // Treat values as equal if their absolute difference is <= 1 + let closeEnough a b = abs (a - b) <= 1 + + let! xs = + taskSeq { yield! [ 10; 11; 9; 20; 21; 5 ] } + |> TaskSeq.distinctUntilChangedWith closeEnough + |> TaskSeq.toListAsync + + // 10≈11 skip; 11≈9 skip (|11-9|=2? no, |11-9|=2>1, so keep 9); 9 vs 20 keep; 20≈21 skip; 21 vs 5 keep + // Wait: |10-11|=1 skip 11; |10-9|=1 skip 9; 10 vs 20 keep 20; |20-21|=1 skip 21; 20 vs 5 keep 5 + xs |> should equal [ 10; 20; 5 ] + } + + [] + let ``TaskSeq-distinctUntilChangedWith with single element returns singleton`` () = task { + let! xs = + taskSeq { yield 99 } + |> TaskSeq.distinctUntilChangedWith (fun _ _ -> true) + |> TaskSeq.toListAsync + + xs |> should equal [ 99 ] + } + + [] + let ``TaskSeq-distinctUntilChangedWith case-insensitive string comparison`` () = task { + let! xs = + taskSeq { yield! [ "Hello"; "hello"; "HELLO"; "World"; "world" ] } + |> TaskSeq.distinctUntilChangedWith (fun a b -> System.String.Compare(a, b, System.StringComparison.OrdinalIgnoreCase) = 0) + |> TaskSeq.toListAsync + + xs |> should equal [ "Hello"; "World" ] + } + + [] + let ``TaskSeq-distinctUntilChangedWithAsync with structural equality behaves like distinctUntilChanged`` () = task { + let ts = [ 1; 1; 2; 3; 3; 4 ] |> TaskSeq.ofList + + let! xs = + ts + |> TaskSeq.distinctUntilChangedWithAsync (fun a b -> task { return a = b }) + |> TaskSeq.toListAsync + + xs |> should equal [ 1; 2; 3; 4 ] + } + + [] + let ``TaskSeq-distinctUntilChangedWithAsync with always-true async comparer returns only first element`` () = task { + let! xs = + taskSeq { yield! [ 10; 20; 30 ] } + |> TaskSeq.distinctUntilChangedWithAsync (fun _ _ -> task { return true }) + |> TaskSeq.toListAsync + + xs |> should equal [ 10 ] + } + + [] + let ``TaskSeq-distinctUntilChangedWithAsync with always-false async comparer returns all elements`` () = task { + let! xs = + taskSeq { yield! [ 5; 5; 5 ] } + |> TaskSeq.distinctUntilChangedWithAsync (fun _ _ -> task { return false }) + |> TaskSeq.toListAsync + + xs |> should equal [ 5; 5; 5 ] + } + + [] + let ``TaskSeq-distinctUntilChangedWithAsync can perform async work in comparer`` () = task { + let mutable comparerCallCount = 0 + + let asyncComparer a b = task { + comparerCallCount <- comparerCallCount + 1 + return a = b + } + + let! xs = + taskSeq { yield! [ 1; 1; 2; 2; 3 ] } + |> TaskSeq.distinctUntilChangedWithAsync asyncComparer + |> TaskSeq.toListAsync + + xs |> should equal [ 1; 2; 3 ] + // comparer called for each pair of consecutive elements (4 pairs for 5 elements) + comparerCallCount |> should equal 4 + } + module SideEffects = [] let ``TaskSeq-distinctUntilChanged consumes every element exactly once`` () = task { @@ -132,3 +283,41 @@ module SideEffects = xs |> should equal [ 1..10 ] } + + [] + let ``TaskSeq-distinctUntilChangedWith consumes every element exactly once`` () = task { + let mutable count = 0 + + let ts = taskSeq { + for i in 1..5 do + count <- count + 1 + yield i + } + + let! xs = + ts + |> TaskSeq.distinctUntilChangedWith (fun a b -> a = b) + |> TaskSeq.toListAsync + + count |> should equal 5 + xs |> should equal [ 1; 2; 3; 4; 5 ] + } + + [] + let ``TaskSeq-distinctUntilChangedWithAsync consumes every element exactly once`` () = task { + let mutable count = 0 + + let ts = taskSeq { + for i in 1..5 do + count <- count + 1 + yield i + } + + let! xs = + ts + |> TaskSeq.distinctUntilChangedWithAsync (fun a b -> task { return a = b }) + |> TaskSeq.toListAsync + + count |> should equal 5 + xs |> should equal [ 1; 2; 3; 4; 5 ] + } diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fs b/src/FSharp.Control.TaskSeq/TaskSeq.fs index 28e1f13..ebe7fd3 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fs @@ -476,6 +476,8 @@ type TaskSeq private () = static member distinctByAsync projection source = Internal.distinctByAsync projection source static member distinctUntilChanged source = Internal.distinctUntilChanged source + static member distinctUntilChangedWith comparer source = Internal.distinctUntilChangedWith comparer source + static member distinctUntilChangedWithAsync comparer source = Internal.distinctUntilChangedWithAsync comparer source static member pairwise source = Internal.pairwise source static member chunkBySize chunkSize source = Internal.chunkBySize chunkSize source static member windowed windowSize source = Internal.windowed windowSize source diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fsi b/src/FSharp.Control.TaskSeq/TaskSeq.fsi index ef34390..6848904 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fsi +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fsi @@ -1503,6 +1503,33 @@ type TaskSeq = /// Thrown when the input task sequences is null. static member distinctUntilChanged<'T when 'T: equality> : source: TaskSeq<'T> -> TaskSeq<'T> + /// + /// Returns a new task sequence without consecutive duplicate elements, using the supplied + /// to determine equality of consecutive elements. The comparer returns if two elements are + /// considered equal (and thus the second should be skipped). + /// + /// + /// A function that returns if two consecutive elements are equal. + /// The input task sequence whose consecutive duplicates will be removed. + /// A sequence without consecutive duplicate elements. + /// + /// Thrown when the input task sequence is null. + static member distinctUntilChangedWith: comparer: ('T -> 'T -> bool) -> source: TaskSeq<'T> -> TaskSeq<'T> + + /// + /// Returns a new task sequence without consecutive duplicate elements, using the supplied async + /// to determine equality of consecutive elements. The comparer returns if two elements are + /// considered equal (and thus the second should be skipped). + /// + /// + /// An async function that returns if two consecutive elements are equal. + /// The input task sequence whose consecutive duplicates will be removed. + /// A sequence without consecutive duplicate elements. + /// + /// Thrown when the input task sequence is null. + static member distinctUntilChangedWithAsync: + comparer: ('T -> 'T -> #Task) -> source: TaskSeq<'T> -> TaskSeq<'T> + /// /// Returns a task sequence of each element in the source paired with its successor. /// The sequence is empty if the source has fewer than two elements. diff --git a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs index 91937ec..23ef20f 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs @@ -1367,6 +1367,46 @@ module internal TaskSeqInternal = maybePrevious <- ValueSome current } + let distinctUntilChangedWith (comparer: 'T -> 'T -> bool) (source: TaskSeq<_>) = + checkNonNull (nameof source) source + + taskSeq { + let mutable maybePrevious = ValueNone + + for current in source do + match maybePrevious with + | ValueNone -> + yield current + maybePrevious <- ValueSome current + | ValueSome previous -> + if comparer previous current then + () // skip + else + yield current + maybePrevious <- ValueSome current + } + + let distinctUntilChangedWithAsync (comparer: 'T -> 'T -> #Task) (source: TaskSeq<_>) = + checkNonNull (nameof source) source + + taskSeq { + let mutable maybePrevious = ValueNone + + for current in source do + match maybePrevious with + | ValueNone -> + yield current + maybePrevious <- ValueSome current + | ValueSome previous -> + let! areEqual = comparer previous current + + if areEqual then + () // skip + else + yield current + maybePrevious <- ValueSome current + } + let pairwise (source: TaskSeq<_>) = checkNonNull (nameof source) source