Skip to content

Commit 3c66430

Browse files
feat/test: add TaskSeq.pairwise; expand distinctUntilChanged tests
Task 10 — Add TaskSeq.pairwise (ref #289): - Implement Internal.pairwise in TaskSeqInternal.fs using the same ValueNone/ValueSome pattern as distinctUntilChanged - Expose as TaskSeq.pairwise static member in TaskSeq.fs + TaskSeq.fsi - Mirrors FSharp.Control.AsyncSeq.pairwise semantics: yields each element paired with its successor; empty for <2 elements Task 9 — Expand distinctUntilChanged tests: - Add Functionality tests: singleton, all-same, all-distinct, alternating - Add Immutable module with TestImmTaskSeq variants - Add SideEffects module: verify element consumption count, correct output - Add TestSideEffectTaskSeq variant test - New test file TaskSeq.Pairwise.Tests.fs: empty/singleton/two-element/ consecutive-pairs/length/boundary/side-effects/variant coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f8789d1 commit 3c66430

File tree

6 files changed

+234
-0
lines changed

6 files changed

+234
-0
lines changed

src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
<Compile Include="TaskSeq.ExactlyOne.Tests.fs" />
2121
<Compile Include="TaskSeq.Except.Tests.fs" />
2222
<Compile Include="TaskSeq.DistinctUntilChanged.Tests.fs" />
23+
<Compile Include="TaskSeq.Pairwise.Tests.fs" />
2324
<Compile Include="TaskSeq.Exists.Tests.fs" />
2425
<Compile Include="TaskSeq.Filter.Tests.fs" />
2526
<Compile Include="TaskSeq.FindIndex.Tests.fs" />

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

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,98 @@ module Functionality =
3737
|> String.concat ""
3838
|> should equal "ABZCZCDZ"
3939
}
40+
41+
[<Fact>]
42+
let ``TaskSeq-distinctUntilChanged with single element returns singleton`` () = task {
43+
let! xs =
44+
taskSeq { yield 42 }
45+
|> TaskSeq.distinctUntilChanged
46+
|> TaskSeq.toListAsync
47+
48+
xs |> should equal [ 42 ]
49+
}
50+
51+
[<Fact>]
52+
let ``TaskSeq-distinctUntilChanged with all identical elements returns one element`` () = task {
53+
let! xs =
54+
taskSeq { yield! [ 7; 7; 7; 7; 7 ] }
55+
|> TaskSeq.distinctUntilChanged
56+
|> TaskSeq.toListAsync
57+
58+
xs |> should equal [ 7 ]
59+
}
60+
61+
[<Fact>]
62+
let ``TaskSeq-distinctUntilChanged with all distinct elements returns all`` () = task {
63+
let! xs =
64+
taskSeq { yield! [ 1; 2; 3; 4; 5 ] }
65+
|> TaskSeq.distinctUntilChanged
66+
|> TaskSeq.toListAsync
67+
68+
xs |> should equal [ 1; 2; 3; 4; 5 ]
69+
}
70+
71+
[<Fact>]
72+
let ``TaskSeq-distinctUntilChanged with alternating pairs`` () = task {
73+
// [A;A;B;B;A;A] -> [A;B;A]
74+
let! xs =
75+
taskSeq { yield! [ 'A'; 'A'; 'B'; 'B'; 'A'; 'A' ] }
76+
|> TaskSeq.distinctUntilChanged
77+
|> TaskSeq.toListAsync
78+
79+
xs |> should equal [ 'A'; 'B'; 'A' ]
80+
}
81+
82+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
83+
let ``TaskSeq-distinctUntilChanged on immutable all-unique seq preserves all elements`` variant = task {
84+
// getSeqImmutable yields 1..10, all unique, so all are returned
85+
let! xs =
86+
Gen.getSeqImmutable variant
87+
|> TaskSeq.distinctUntilChanged
88+
|> TaskSeq.toListAsync
89+
90+
xs |> should equal [ 1..10 ]
91+
}
92+
93+
module SideEffects =
94+
[<Fact>]
95+
let ``TaskSeq-distinctUntilChanged consumes every element exactly once`` () = task {
96+
let mutable count = 0
97+
98+
let ts = taskSeq {
99+
for i in 1..6 do
100+
count <- count + 1
101+
yield i % 3 // yields 1,2,0,1,2,0 — no consecutive duplicates
102+
}
103+
104+
let! xs = ts |> TaskSeq.distinctUntilChanged |> TaskSeq.toListAsync
105+
count |> should equal 6
106+
xs |> should equal [ 1; 2; 0; 1; 2; 0 ]
107+
}
108+
109+
[<Fact>]
110+
let ``TaskSeq-distinctUntilChanged skips duplicates without extra evaluation`` () = task {
111+
let mutable count = 0
112+
113+
let ts = taskSeq {
114+
for i in [ 1; 1; 2; 2; 3 ] do
115+
count <- count + 1
116+
yield i
117+
}
118+
119+
let! xs = ts |> TaskSeq.distinctUntilChanged |> TaskSeq.toListAsync
120+
// All 5 source elements must still be consumed
121+
count |> should equal 5
122+
xs |> should equal [ 1; 2; 3 ]
123+
}
124+
125+
[<Theory; ClassData(typeof<TestSideEffectTaskSeq>)>]
126+
let ``TaskSeq-distinctUntilChanged on side-effect seq preserves all unique elements`` variant = task {
127+
// getSeqWithSideEffect yields 1..10 (all unique on first iteration)
128+
let! xs =
129+
Gen.getSeqWithSideEffect variant
130+
|> TaskSeq.distinctUntilChanged
131+
|> TaskSeq.toListAsync
132+
133+
xs |> should equal [ 1..10 ]
134+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
module TaskSeq.Tests.Pairwise
2+
3+
open Xunit
4+
open FsUnit.Xunit
5+
6+
open FSharp.Control
7+
8+
//
9+
// TaskSeq.pairwise
10+
//
11+
12+
13+
module EmptySeq =
14+
[<Fact>]
15+
let ``TaskSeq-pairwise with null source raises`` () = assertNullArg <| fun () -> TaskSeq.pairwise null
16+
17+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
18+
let ``TaskSeq-pairwise on empty returns empty`` variant =
19+
Gen.getEmptyVariant variant
20+
|> TaskSeq.pairwise
21+
|> verifyEmpty
22+
23+
[<Fact>]
24+
let ``TaskSeq-pairwise on singleton returns empty`` () = taskSeq { yield 42 } |> TaskSeq.pairwise |> verifyEmpty
25+
26+
module Immutable =
27+
[<Fact>]
28+
let ``TaskSeq-pairwise on two elements returns one pair`` () = task {
29+
let! pairs =
30+
taskSeq { yield! [ 10; 20 ] }
31+
|> TaskSeq.pairwise
32+
|> TaskSeq.toListAsync
33+
34+
pairs |> should equal [ (10, 20) ]
35+
}
36+
37+
[<Fact>]
38+
let ``TaskSeq-pairwise returns consecutive overlapping pairs`` () = task {
39+
let! pairs =
40+
taskSeq { yield! [ 1..5 ] }
41+
|> TaskSeq.pairwise
42+
|> TaskSeq.toListAsync
43+
44+
pairs |> should equal [ (1, 2); (2, 3); (3, 4); (4, 5) ]
45+
}
46+
47+
[<Fact>]
48+
let ``TaskSeq-pairwise output length is source length minus one`` () = task {
49+
let! len =
50+
taskSeq { yield! [ 1..10 ] }
51+
|> TaskSeq.pairwise
52+
|> TaskSeq.length
53+
54+
len |> should equal 9
55+
}
56+
57+
[<Fact>]
58+
let ``TaskSeq-pairwise shares elements across adjacent pairs`` () = task {
59+
// element at index i is the right of pair i-1 and the left of pair i
60+
let! pairs =
61+
taskSeq { yield! [ 'A'; 'B'; 'C'; 'D' ] }
62+
|> TaskSeq.pairwise
63+
|> TaskSeq.toListAsync
64+
65+
pairs |> should equal [ ('A', 'B'); ('B', 'C'); ('C', 'D') ]
66+
// check that middle elements appear in both adjacent pairs
67+
let (_, r0) = pairs[0]
68+
let (l1, _) = pairs[1]
69+
r0 |> should equal l1
70+
}
71+
72+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
73+
let ``TaskSeq-pairwise all variants - correct count and boundaries`` variant = task {
74+
// getSeqImmutable yields 1..10 → 9 pairs (1,2)..(9,10)
75+
let! pairs =
76+
Gen.getSeqImmutable variant
77+
|> TaskSeq.pairwise
78+
|> TaskSeq.toListAsync
79+
80+
pairs |> List.length |> should equal 9
81+
pairs |> List.head |> should equal (1, 2)
82+
pairs |> List.last |> should equal (9, 10)
83+
}
84+
85+
module SideEffects =
86+
[<Fact>]
87+
let ``TaskSeq-pairwise consumes every source element exactly once`` () = task {
88+
let mutable count = 0
89+
90+
let ts = taskSeq {
91+
for i in 1..5 do
92+
count <- count + 1
93+
yield i
94+
}
95+
96+
let! pairs = ts |> TaskSeq.pairwise |> TaskSeq.toListAsync
97+
count |> should equal 5
98+
pairs |> should equal [ (1, 2); (2, 3); (3, 4); (4, 5) ]
99+
}
100+
101+
[<Theory; ClassData(typeof<TestSideEffectTaskSeq>)>]
102+
let ``TaskSeq-pairwise on side-effect seq yields correct pairs`` variant = task {
103+
// getSeqWithSideEffect yields 1..10 on first iteration
104+
let! pairs =
105+
Gen.getSeqWithSideEffect variant
106+
|> TaskSeq.pairwise
107+
|> TaskSeq.toListAsync
108+
109+
pairs |> List.length |> should equal 9
110+
pairs |> List.head |> should equal (1, 2)
111+
pairs |> List.last |> should equal (9, 10)
112+
}

src/FSharp.Control.TaskSeq/TaskSeq.fs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,7 @@ type TaskSeq private () =
359359
static member exceptOfSeq itemsToExclude source = Internal.exceptOfSeq itemsToExclude source
360360

361361
static member distinctUntilChanged source = Internal.distinctUntilChanged source
362+
static member pairwise source = Internal.pairwise source
362363

363364
static member forall predicate source = Internal.forall (Predicate predicate) source
364365
static member forallAsync predicate source = Internal.forall (PredicateAsync predicate) source

src/FSharp.Control.TaskSeq/TaskSeq.fsi

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,6 +1307,17 @@ type TaskSeq =
13071307
/// <exception cref="T:ArgumentNullException">Thrown when the input task sequences is null.</exception>
13081308
static member distinctUntilChanged<'T when 'T: equality> : source: TaskSeq<'T> -> TaskSeq<'T>
13091309

1310+
/// <summary>
1311+
/// Returns a task sequence of each element in the source paired with its successor.
1312+
/// The sequence is empty if the source has fewer than two elements.
1313+
/// </summary>
1314+
///
1315+
/// <param name="source">The input task sequence.</param>
1316+
/// <returns>A task sequence of consecutive element pairs.</returns>
1317+
///
1318+
/// <exception cref="T:ArgumentNullException">Thrown when the input task sequence is null.</exception>
1319+
static member pairwise: source: TaskSeq<'T> -> TaskSeq<'T * 'T>
1320+
13101321
/// <summary>
13111322
/// Combines the two task sequences into a new task sequence of pairs. The two sequences need not have equal lengths:
13121323
/// when one sequence is exhausted any remaining elements in the other sequence are ignored.

src/FSharp.Control.TaskSeq/TaskSeqInternal.fs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,3 +1100,17 @@ module internal TaskSeqInternal =
11001100
yield current
11011101
maybePrevious <- ValueSome current
11021102
}
1103+
1104+
let pairwise (source: TaskSeq<_>) =
1105+
checkNonNull (nameof source) source
1106+
1107+
taskSeq {
1108+
let mutable maybePrevious = ValueNone
1109+
1110+
for current in source do
1111+
match maybePrevious with
1112+
| ValueNone -> maybePrevious <- ValueSome current
1113+
| ValueSome previous ->
1114+
yield previous, current
1115+
maybePrevious <- ValueSome current
1116+
}

0 commit comments

Comments
 (0)