Skip to content

Commit 0429b8c

Browse files
feat: add TaskSeq.distinctUntilChangedWith and distinctUntilChangedWithAsync
Adds two new functions that extend the existing distinctUntilChanged with a user-supplied comparer predicate: - TaskSeq.distinctUntilChangedWith: ('T -> 'T -> bool) -> TaskSeq<'T> -> TaskSeq<'T> - TaskSeq.distinctUntilChangedWithAsync: ('T -> 'T -> #Task<bool>) -> TaskSeq<'T> -> TaskSeq<'T> Both functions yield elements from the source, skipping any element that compares equal to its predecessor according to the supplied comparer. They mirror the existing distinctUntilChanged but give callers control over equality (e.g. case-insensitive strings, fuzzy numeric comparison, projected keys). Includes 40 new tests (63 total in the DistinctUntilChanged test file). All 4,741 tests pass. Relates to #345 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8f3a22e commit 0429b8c

File tree

5 files changed

+260
-1
lines changed

5 files changed

+260
-1
lines changed

release-notes.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Release notes:
33

44
1.0.0
5+
- adds TaskSeq.distinctUntilChangedWith and TaskSeq.distinctUntilChangedWithAsync, #345
56
- adds TaskSeq.withCancellation, #167
67
- adds docs/ with fsdocs-based documentation site covering generating, transforming, consuming, combining and advanced operations
78

src/FSharp.Control.TaskSeq.Test/TaskSeq.DistinctUntilChanged.Tests.fs

Lines changed: 190 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ open FsUnit.Xunit
66
open FSharp.Control
77

88
//
9-
// TaskSeq.distinctUntilChanged
9+
// TaskSeq.distinctUntilChanged / distinctUntilChangedWith / distinctUntilChangedWithAsync
1010
//
1111

1212

@@ -23,6 +23,34 @@ module EmptySeq =
2323
|> Task.map (List.isEmpty >> should be True)
2424
}
2525

26+
[<Fact>]
27+
let ``TaskSeq-distinctUntilChangedWith with null source raises`` () =
28+
assertNullArg
29+
<| fun () -> TaskSeq.distinctUntilChangedWith (fun _ _ -> false) null
30+
31+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
32+
let ``TaskSeq-distinctUntilChangedWith has no effect on empty`` variant = task {
33+
do!
34+
Gen.getEmptyVariant variant
35+
|> TaskSeq.distinctUntilChangedWith (fun _ _ -> false)
36+
|> TaskSeq.toListAsync
37+
|> Task.map (List.isEmpty >> should be True)
38+
}
39+
40+
[<Fact>]
41+
let ``TaskSeq-distinctUntilChangedWithAsync with null source raises`` () =
42+
assertNullArg
43+
<| fun () -> TaskSeq.distinctUntilChangedWithAsync (fun _ _ -> task { return false }) null
44+
45+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
46+
let ``TaskSeq-distinctUntilChangedWithAsync has no effect on empty`` variant = task {
47+
do!
48+
Gen.getEmptyVariant variant
49+
|> TaskSeq.distinctUntilChangedWithAsync (fun _ _ -> task { return false })
50+
|> TaskSeq.toListAsync
51+
|> Task.map (List.isEmpty >> should be True)
52+
}
53+
2654
module Functionality =
2755
[<Fact>]
2856
let ``TaskSeq-distinctUntilChanged should return no consecutive duplicates`` () = task {
@@ -90,6 +118,129 @@ module Functionality =
90118
xs |> should equal [ 1..10 ]
91119
}
92120

121+
[<Fact>]
122+
let ``TaskSeq-distinctUntilChangedWith with structural equality comparer behaves like distinctUntilChanged`` () = task {
123+
let ts =
124+
[ 'A'; 'A'; 'B'; 'Z'; 'C'; 'C'; 'Z'; 'C'; 'D'; 'D'; 'D'; 'Z' ]
125+
|> TaskSeq.ofList
126+
127+
let! xs =
128+
ts
129+
|> TaskSeq.distinctUntilChangedWith (=)
130+
|> TaskSeq.toListAsync
131+
132+
xs
133+
|> List.map string
134+
|> String.concat ""
135+
|> should equal "ABZCZCDZ"
136+
}
137+
138+
[<Fact>]
139+
let ``TaskSeq-distinctUntilChangedWith with always-true comparer returns only first element`` () = task {
140+
let! xs =
141+
taskSeq { yield! [ 1; 2; 3; 4; 5 ] }
142+
|> TaskSeq.distinctUntilChangedWith (fun _ _ -> true)
143+
|> TaskSeq.toListAsync
144+
145+
xs |> should equal [ 1 ]
146+
}
147+
148+
[<Fact>]
149+
let ``TaskSeq-distinctUntilChangedWith with always-false comparer returns all elements`` () = task {
150+
let! xs =
151+
taskSeq { yield! [ 1; 1; 2; 2; 3 ] }
152+
|> TaskSeq.distinctUntilChangedWith (fun _ _ -> false)
153+
|> TaskSeq.toListAsync
154+
155+
xs |> should equal [ 1; 1; 2; 2; 3 ]
156+
}
157+
158+
[<Fact>]
159+
let ``TaskSeq-distinctUntilChangedWith can use custom projection for equality`` () = task {
160+
// Treat values as equal if their absolute difference is <= 1
161+
let closeEnough a b = abs (a - b) <= 1
162+
163+
let! xs =
164+
taskSeq { yield! [ 10; 11; 9; 20; 21; 5 ] }
165+
|> TaskSeq.distinctUntilChangedWith closeEnough
166+
|> TaskSeq.toListAsync
167+
168+
// 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
169+
// 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
170+
xs |> should equal [ 10; 20; 5 ]
171+
}
172+
173+
[<Fact>]
174+
let ``TaskSeq-distinctUntilChangedWith with single element returns singleton`` () = task {
175+
let! xs =
176+
taskSeq { yield 99 }
177+
|> TaskSeq.distinctUntilChangedWith (fun _ _ -> true)
178+
|> TaskSeq.toListAsync
179+
180+
xs |> should equal [ 99 ]
181+
}
182+
183+
[<Fact>]
184+
let ``TaskSeq-distinctUntilChangedWith case-insensitive string comparison`` () = task {
185+
let! xs =
186+
taskSeq { yield! [ "Hello"; "hello"; "HELLO"; "World"; "world" ] }
187+
|> TaskSeq.distinctUntilChangedWith (fun a b -> System.String.Compare(a, b, System.StringComparison.OrdinalIgnoreCase) = 0)
188+
|> TaskSeq.toListAsync
189+
190+
xs |> should equal [ "Hello"; "World" ]
191+
}
192+
193+
[<Fact>]
194+
let ``TaskSeq-distinctUntilChangedWithAsync with structural equality behaves like distinctUntilChanged`` () = task {
195+
let ts = [ 1; 1; 2; 3; 3; 4 ] |> TaskSeq.ofList
196+
197+
let! xs =
198+
ts
199+
|> TaskSeq.distinctUntilChangedWithAsync (fun a b -> task { return a = b })
200+
|> TaskSeq.toListAsync
201+
202+
xs |> should equal [ 1; 2; 3; 4 ]
203+
}
204+
205+
[<Fact>]
206+
let ``TaskSeq-distinctUntilChangedWithAsync with always-true async comparer returns only first element`` () = task {
207+
let! xs =
208+
taskSeq { yield! [ 10; 20; 30 ] }
209+
|> TaskSeq.distinctUntilChangedWithAsync (fun _ _ -> task { return true })
210+
|> TaskSeq.toListAsync
211+
212+
xs |> should equal [ 10 ]
213+
}
214+
215+
[<Fact>]
216+
let ``TaskSeq-distinctUntilChangedWithAsync with always-false async comparer returns all elements`` () = task {
217+
let! xs =
218+
taskSeq { yield! [ 5; 5; 5 ] }
219+
|> TaskSeq.distinctUntilChangedWithAsync (fun _ _ -> task { return false })
220+
|> TaskSeq.toListAsync
221+
222+
xs |> should equal [ 5; 5; 5 ]
223+
}
224+
225+
[<Fact>]
226+
let ``TaskSeq-distinctUntilChangedWithAsync can perform async work in comparer`` () = task {
227+
let mutable comparerCallCount = 0
228+
229+
let asyncComparer a b = task {
230+
comparerCallCount <- comparerCallCount + 1
231+
return a = b
232+
}
233+
234+
let! xs =
235+
taskSeq { yield! [ 1; 1; 2; 2; 3 ] }
236+
|> TaskSeq.distinctUntilChangedWithAsync asyncComparer
237+
|> TaskSeq.toListAsync
238+
239+
xs |> should equal [ 1; 2; 3 ]
240+
// comparer called for each pair of consecutive elements (4 pairs for 5 elements)
241+
comparerCallCount |> should equal 4
242+
}
243+
93244
module SideEffects =
94245
[<Fact>]
95246
let ``TaskSeq-distinctUntilChanged consumes every element exactly once`` () = task {
@@ -132,3 +283,41 @@ module SideEffects =
132283

133284
xs |> should equal [ 1..10 ]
134285
}
286+
287+
[<Fact>]
288+
let ``TaskSeq-distinctUntilChangedWith consumes every element exactly once`` () = task {
289+
let mutable count = 0
290+
291+
let ts = taskSeq {
292+
for i in 1..5 do
293+
count <- count + 1
294+
yield i
295+
}
296+
297+
let! xs =
298+
ts
299+
|> TaskSeq.distinctUntilChangedWith (fun a b -> a = b)
300+
|> TaskSeq.toListAsync
301+
302+
count |> should equal 5
303+
xs |> should equal [ 1; 2; 3; 4; 5 ]
304+
}
305+
306+
[<Fact>]
307+
let ``TaskSeq-distinctUntilChangedWithAsync consumes every element exactly once`` () = task {
308+
let mutable count = 0
309+
310+
let ts = taskSeq {
311+
for i in 1..5 do
312+
count <- count + 1
313+
yield i
314+
}
315+
316+
let! xs =
317+
ts
318+
|> TaskSeq.distinctUntilChangedWithAsync (fun a b -> task { return a = b })
319+
|> TaskSeq.toListAsync
320+
321+
count |> should equal 5
322+
xs |> should equal [ 1; 2; 3; 4; 5 ]
323+
}

src/FSharp.Control.TaskSeq/TaskSeq.fs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,8 @@ type TaskSeq private () =
476476
static member distinctByAsync projection source = Internal.distinctByAsync projection source
477477

478478
static member distinctUntilChanged source = Internal.distinctUntilChanged source
479+
static member distinctUntilChangedWith comparer source = Internal.distinctUntilChangedWith comparer source
480+
static member distinctUntilChangedWithAsync comparer source = Internal.distinctUntilChangedWithAsync comparer source
479481
static member pairwise source = Internal.pairwise source
480482
static member chunkBySize chunkSize source = Internal.chunkBySize chunkSize source
481483
static member windowed windowSize source = Internal.windowed windowSize source

src/FSharp.Control.TaskSeq/TaskSeq.fsi

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1503,6 +1503,33 @@ type TaskSeq =
15031503
/// <exception cref="T:ArgumentNullException">Thrown when the input task sequences is null.</exception>
15041504
static member distinctUntilChanged<'T when 'T: equality> : source: TaskSeq<'T> -> TaskSeq<'T>
15051505

1506+
/// <summary>
1507+
/// Returns a new task sequence without consecutive duplicate elements, using the supplied <paramref name="comparer" />
1508+
/// to determine equality of consecutive elements. The comparer returns <see langword="true" /> if two elements are
1509+
/// considered equal (and thus the second should be skipped).
1510+
/// </summary>
1511+
///
1512+
/// <param name="comparer">A function that returns <see langword="true" /> if two consecutive elements are equal.</param>
1513+
/// <param name="source">The input task sequence whose consecutive duplicates will be removed.</param>
1514+
/// <returns>A sequence without consecutive duplicate elements.</returns>
1515+
///
1516+
/// <exception cref="T:ArgumentNullException">Thrown when the input task sequence is null.</exception>
1517+
static member distinctUntilChangedWith: comparer: ('T -> 'T -> bool) -> source: TaskSeq<'T> -> TaskSeq<'T>
1518+
1519+
/// <summary>
1520+
/// Returns a new task sequence without consecutive duplicate elements, using the supplied async <paramref name="comparer" />
1521+
/// to determine equality of consecutive elements. The comparer returns <see langword="true" /> if two elements are
1522+
/// considered equal (and thus the second should be skipped).
1523+
/// </summary>
1524+
///
1525+
/// <param name="comparer">An async function that returns <see langword="true" /> if two consecutive elements are equal.</param>
1526+
/// <param name="source">The input task sequence whose consecutive duplicates will be removed.</param>
1527+
/// <returns>A sequence without consecutive duplicate elements.</returns>
1528+
///
1529+
/// <exception cref="T:ArgumentNullException">Thrown when the input task sequence is null.</exception>
1530+
static member distinctUntilChangedWithAsync:
1531+
comparer: ('T -> 'T -> #Task<bool>) -> source: TaskSeq<'T> -> TaskSeq<'T>
1532+
15061533
/// <summary>
15071534
/// Returns a task sequence of each element in the source paired with its successor.
15081535
/// The sequence is empty if the source has fewer than two elements.

src/FSharp.Control.TaskSeq/TaskSeqInternal.fs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1367,6 +1367,46 @@ module internal TaskSeqInternal =
13671367
maybePrevious <- ValueSome current
13681368
}
13691369

1370+
let distinctUntilChangedWith (comparer: 'T -> 'T -> bool) (source: TaskSeq<_>) =
1371+
checkNonNull (nameof source) source
1372+
1373+
taskSeq {
1374+
let mutable maybePrevious = ValueNone
1375+
1376+
for current in source do
1377+
match maybePrevious with
1378+
| ValueNone ->
1379+
yield current
1380+
maybePrevious <- ValueSome current
1381+
| ValueSome previous ->
1382+
if comparer previous current then
1383+
() // skip
1384+
else
1385+
yield current
1386+
maybePrevious <- ValueSome current
1387+
}
1388+
1389+
let distinctUntilChangedWithAsync (comparer: 'T -> 'T -> #Task<bool>) (source: TaskSeq<_>) =
1390+
checkNonNull (nameof source) source
1391+
1392+
taskSeq {
1393+
let mutable maybePrevious = ValueNone
1394+
1395+
for current in source do
1396+
match maybePrevious with
1397+
| ValueNone ->
1398+
yield current
1399+
maybePrevious <- ValueSome current
1400+
| ValueSome previous ->
1401+
let! areEqual = comparer previous current
1402+
1403+
if areEqual then
1404+
() // skip
1405+
else
1406+
yield current
1407+
maybePrevious <- ValueSome current
1408+
}
1409+
13701410
let pairwise (source: TaskSeq<_>) =
13711411
checkNonNull (nameof source) source
13721412

0 commit comments

Comments
 (0)