Skip to content

Commit 0a2a9b8

Browse files
authored
Merge branch 'main' into repo-assist/test-unfold-sideeffects-20260423-240f686bd62cbcbc
2 parents a52c969 + 0d3967a commit 0a2a9b8

9 files changed

Lines changed: 445 additions & 2 deletions

File tree

.github/workflows/main.yaml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,35 @@ jobs:
6565
# this path glob pattern requires forward slashes!
6666
path: ./src/FSharp.Control.TaskSeq.Test/TestResults/test-results-release.trx
6767
reporter: dotnet-trx
68+
69+
test-release-linux:
70+
name: Test Release Build (Linux)
71+
runs-on: ubuntu-latest
72+
steps:
73+
- name: checkout-code
74+
uses: actions/checkout@v6
75+
with:
76+
fetch-depth: 0
77+
78+
- name: setup-dotnet
79+
uses: actions/setup-dotnet@v4
80+
81+
- name: Cache NuGet packages
82+
uses: actions/cache@v5
83+
with:
84+
path: ~/.nuget/packages
85+
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.fsproj', '**/*.csproj', 'global.json') }}
86+
restore-keys: nuget-${{ runner.os }}-
87+
88+
# build.cmd is Windows-only; run dotnet test directly on Linux
89+
- name: Run dotnet test - release (Linux)
90+
run: dotnet test src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj -c Release --blame-hang-timeout 60000ms --logger "console;verbosity=detailed" --logger "trx;LogFileName=test-results-release-linux.trx"
91+
92+
- name: Publish test results - release (Linux)
93+
uses: dorny/test-reporter@v2
94+
if: always()
95+
with:
96+
name: Report release tests (Linux)
97+
# this path glob pattern requires forward slashes!
98+
path: ./src/FSharp.Control.TaskSeq.Test/TestResults/test-results-release-linux.trx
99+
reporter: dotnet-trx

release-notes.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ Release notes:
33

44
Unreleased
55
- test: add SideEffects module to TaskSeq.Unfold.Tests.fs, verifying generator call counts, re-iteration behaviour, early-termination via take, and exception propagation
6+
- adds TaskSeq.foldWhile and TaskSeq.foldWhileAsync: fold with early termination via a (state, element) -> bool predicate (takeWhile-style, exclusive). When the predicate returns false, iteration halts without folding that element; no further elements are enumerated.
67
- feat: add `TaskSeq.toChannelAsync` and `TaskSeq.ofChannel` for bidirectional `System.Threading.Channels` integration, closing #415
78
- eng: update PackageValidationBaselineVersion from 0.4.0 to 1.1.1 to enforce binary compatibility checks against the current stable release
89
- test: add SideEffects module and ImmTaskSeq variant tests to TaskSeq.ChunkBy.Tests.fs, improving coverage for chunkBy and chunkByAsync
10+
- test: add SideEffects module to TaskSeq.WithCancellation.Tests.fs, verifying re-iteration semantics are preserved when wrapping with a CancellationToken
911
- fixes: `Async.bind` signature corrected from `(Async<'T> -> Async<'U>)` to `('T -> Async<'U>)` to match standard monadic bind semantics (same as `Task.bind`); the previous signature made the function effectively equivalent to direct application
1012
- refactor: simplify splitAt 'rest' taskSeq to use while!, removing redundant go2 mutable and manual MoveNextAsync pre-advance
1113

src/FSharp.Control.TaskSeq.SmokeTests/FSharp.Control.TaskSeq.SmokeTests.fsproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
highest version available.
2020
This ensures that, if we have a forwards compat issue, we will get an error.
2121
-->
22-
<PackageReference Include="FSharp.Control.TaskSeq" Version="0.4.0" />
23-
<PackageReference Include="FsToolkit.ErrorHandling.TaskResult" Version="4.15.1" />
22+
<PackageReference Include="FSharp.Control.TaskSeq" Version="1.1.1" />
23+
<PackageReference Include="FsToolkit.ErrorHandling.TaskResult" Version="4.18.0" />
2424
<PackageReference Include="FsUnit.xUnit" Version="6.0.1" />
2525
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
2626
<PackageReference Include="xunit" Version="2.9.3" />

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
<Compile Include="TaskSeq.FindIndex.Tests.fs" />
2929
<Compile Include="TaskSeq.Find.Tests.fs" />
3030
<Compile Include="TaskSeq.Fold.Tests.fs" />
31+
<Compile Include="TaskSeq.FoldWhile.Tests.fs" />
3132
<Compile Include="TaskSeq.Scan.Tests.fs" />
3233
<Compile Include="TaskSeq.MapFold.Tests.fs" />
3334
<Compile Include="TaskSeq.Reduce.Tests.fs" />
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
module TaskSeq.Tests.FoldWhile
2+
3+
open Xunit
4+
open FsUnit.Xunit
5+
6+
open FSharp.Control
7+
8+
//
9+
// TaskSeq.foldWhile
10+
// TaskSeq.foldWhileAsync
11+
//
12+
// Semantics match TaskSeq.takeWhile: the predicate is evaluated against (state, element)
13+
// before that element is folded in. When the predicate returns false, iteration halts
14+
// without folding that element, and no further elements are enumerated.
15+
//
16+
17+
module EmptySeq =
18+
[<Fact>]
19+
let ``Null source is invalid`` () =
20+
assertNullArg
21+
<| fun () -> TaskSeq.foldWhile (fun _ _ -> true) (fun _ item -> item + 1) 0 null
22+
23+
assertNullArg
24+
<| fun () -> TaskSeq.foldWhileAsync (fun _ _ -> Task.fromResult true) (fun _ item -> Task.fromResult (item + 1)) 0 null
25+
26+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
27+
let ``TaskSeq-foldWhile returns initial state when empty`` variant = task {
28+
let! result =
29+
Gen.getEmptyVariant variant
30+
|> TaskSeq.foldWhile (fun _ _ -> true) (fun _ item -> item + 1) -1
31+
32+
result |> should equal -1
33+
}
34+
35+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
36+
let ``TaskSeq-foldWhileAsync returns initial state when empty`` variant = task {
37+
let! result =
38+
Gen.getEmptyVariant variant
39+
|> TaskSeq.foldWhileAsync (fun _ _ -> Task.fromResult true) (fun _ item -> Task.fromResult (item + 1)) -1
40+
41+
result |> should equal -1
42+
}
43+
44+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
45+
let ``TaskSeq-foldWhile does not call predicate or folder when empty`` variant = task {
46+
let mutable predicateCalled = false
47+
let mutable folderCalled = false
48+
49+
let! _ =
50+
Gen.getEmptyVariant variant
51+
|> TaskSeq.foldWhile
52+
(fun _ _ ->
53+
predicateCalled <- true
54+
true)
55+
(fun state _ ->
56+
folderCalled <- true
57+
state)
58+
0
59+
60+
predicateCalled |> should be False
61+
folderCalled |> should be False
62+
}
63+
64+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
65+
let ``TaskSeq-foldWhileAsync does not call predicate or folder when empty`` variant = task {
66+
let mutable predicateCalled = false
67+
let mutable folderCalled = false
68+
69+
let! _ =
70+
Gen.getEmptyVariant variant
71+
|> TaskSeq.foldWhileAsync
72+
(fun _ _ -> task {
73+
predicateCalled <- true
74+
return true
75+
})
76+
(fun state _ -> task {
77+
folderCalled <- true
78+
return state
79+
})
80+
0
81+
82+
predicateCalled |> should be False
83+
folderCalled |> should be False
84+
}
85+
86+
module Functionality =
87+
[<Fact>]
88+
let ``TaskSeq-foldWhile with always-true predicate behaves like fold`` () = task {
89+
let! result =
90+
TaskSeq.ofList [ 1; 2; 3; 4; 5 ]
91+
|> TaskSeq.foldWhile (fun _ _ -> true) (fun acc item -> acc + item) 0
92+
93+
result |> should equal 15
94+
}
95+
96+
[<Fact>]
97+
let ``TaskSeq-foldWhileAsync with always-true predicate behaves like foldAsync`` () = task {
98+
let! result =
99+
TaskSeq.ofList [ 1; 2; 3; 4; 5 ]
100+
|> TaskSeq.foldWhileAsync (fun _ _ -> Task.fromResult true) (fun acc item -> Task.fromResult (acc + item)) 0
101+
102+
result |> should equal 15
103+
}
104+
105+
[<Fact>]
106+
let ``TaskSeq-foldWhile is left-associative like fold`` () = task {
107+
let! result =
108+
TaskSeq.ofList [ "b"; "c"; "d" ]
109+
|> TaskSeq.foldWhile (fun _ _ -> true) (fun acc item -> acc + item) "a"
110+
111+
result |> should equal "abcd"
112+
}
113+
114+
[<Fact>]
115+
let ``TaskSeq-foldWhileAsync is left-associative like foldAsync`` () = task {
116+
let! result =
117+
TaskSeq.ofList [ "b"; "c"; "d" ]
118+
|> TaskSeq.foldWhileAsync (fun _ _ -> Task.fromResult true) (fun acc item -> Task.fromResult (acc + item)) "a"
119+
120+
result |> should equal "abcd"
121+
}
122+
123+
module Halt =
124+
[<Fact>]
125+
let ``TaskSeq-foldWhile stops immediately when predicate is false on first element`` () = task {
126+
let mutable predicateCalls = 0
127+
let mutable folderCalls = 0
128+
129+
let! result =
130+
TaskSeq.ofList [ 1; 2; 3; 4; 5 ]
131+
|> TaskSeq.foldWhile
132+
(fun _ _ ->
133+
predicateCalls <- predicateCalls + 1
134+
false)
135+
(fun _ item ->
136+
folderCalls <- folderCalls + 1
137+
item)
138+
0
139+
140+
result |> should equal 0
141+
predicateCalls |> should equal 1
142+
folderCalls |> should equal 0
143+
}
144+
145+
[<Fact>]
146+
let ``TaskSeq-foldWhileAsync stops immediately when predicate is false on first element`` () = task {
147+
let mutable predicateCalls = 0
148+
let mutable folderCalls = 0
149+
150+
let! result =
151+
TaskSeq.ofList [ 1; 2; 3; 4; 5 ]
152+
|> TaskSeq.foldWhileAsync
153+
(fun _ _ -> task {
154+
predicateCalls <- predicateCalls + 1
155+
return false
156+
})
157+
(fun _ item -> task {
158+
folderCalls <- folderCalls + 1
159+
return item
160+
})
161+
0
162+
163+
result |> should equal 0
164+
predicateCalls |> should equal 1
165+
folderCalls |> should equal 0
166+
}
167+
168+
[<Fact>]
169+
let ``TaskSeq-foldWhile halts mid-sequence without folding the halting element`` () = task {
170+
// Sum while adding the next element would keep the total <= 5. Once adding
171+
// the element would overshoot, stop — that element is NOT folded in.
172+
let mutable predicateCalls = 0
173+
let mutable folderCalls = 0
174+
175+
let! result =
176+
TaskSeq.ofList [ 1; 2; 3; 4; 5 ]
177+
|> TaskSeq.foldWhile
178+
(fun acc item ->
179+
predicateCalls <- predicateCalls + 1
180+
acc + item <= 5)
181+
(fun acc item ->
182+
folderCalls <- folderCalls + 1
183+
acc + item)
184+
0
185+
186+
// 1 (ok, total 1), 2 (ok, total 3), 3 (would make 6 > 5, stop)
187+
result |> should equal 3
188+
predicateCalls |> should equal 3
189+
folderCalls |> should equal 2
190+
}
191+
192+
[<Fact>]
193+
let ``TaskSeq-foldWhileAsync halts mid-sequence without folding the halting element`` () = task {
194+
let mutable predicateCalls = 0
195+
let mutable folderCalls = 0
196+
197+
let! result =
198+
TaskSeq.ofList [ 1; 2; 3; 4; 5 ]
199+
|> TaskSeq.foldWhileAsync
200+
(fun acc item -> task {
201+
predicateCalls <- predicateCalls + 1
202+
return acc + item <= 5
203+
})
204+
(fun acc item -> task {
205+
folderCalls <- folderCalls + 1
206+
return acc + item
207+
})
208+
0
209+
210+
result |> should equal 3
211+
predicateCalls |> should equal 3
212+
folderCalls |> should equal 2
213+
}
214+
215+
[<Fact>]
216+
let ``TaskSeq-foldWhile does not enumerate past the halting element`` () = task {
217+
// Source has a side effect per pulled element; halt on the 3rd pull.
218+
let mutable pulled = 0
219+
220+
let source = taskSeq {
221+
for i in 1..5 do
222+
pulled <- pulled + 1
223+
yield i
224+
}
225+
226+
let! _ =
227+
source
228+
|> TaskSeq.foldWhile (fun _ item -> item < 3) (fun acc item -> acc + item) 0
229+
230+
// Pull 1 (ok), pull 2 (ok), pull 3 (predicate false, stop) — must not pull 4.
231+
pulled |> should equal 3
232+
}
233+
234+
[<Fact>]
235+
let ``TaskSeq-foldWhileAsync does not enumerate past the halting element`` () = task {
236+
let mutable pulled = 0
237+
238+
let source = taskSeq {
239+
for i in 1..5 do
240+
pulled <- pulled + 1
241+
yield i
242+
}
243+
244+
let! _ =
245+
source
246+
|> TaskSeq.foldWhileAsync (fun _ item -> Task.fromResult (item < 3)) (fun acc item -> Task.fromResult (acc + item)) 0
247+
248+
pulled |> should equal 3
249+
}
250+
251+
[<Fact>]
252+
let ``TaskSeq-foldWhile that never halts is equivalent to fold`` () = task {
253+
let! result =
254+
TaskSeq.ofList [ 1; 2; 3; 4; 5 ]
255+
|> TaskSeq.foldWhile (fun _ item -> item <= 10) (fun acc item -> acc + item) 0
256+
257+
result |> should equal 15
258+
}

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,57 @@ module ``Sequence contents`` =
170170

171171
collected |> Seq.toArray |> should equal [| 1..5 |]
172172
}
173+
174+
module SideEffects =
175+
176+
[<Theory; ClassData(typeof<TestSideEffectTaskSeq>)>]
177+
let ``TaskSeq-withCancellation applied multiple times`` variant = task {
178+
let ts = Gen.getSeqWithSideEffect variant
179+
let wrapped = TaskSeq.withCancellation CancellationToken.None ts
180+
181+
let! first = wrapped |> TaskSeq.toArrayAsync
182+
let! second = wrapped |> TaskSeq.toArrayAsync
183+
let! third = wrapped |> TaskSeq.toArrayAsync
184+
185+
first |> should equal [| 1..10 |]
186+
second |> should equal [| 11..20 |]
187+
third |> should equal [| 21..30 |]
188+
}
189+
190+
[<Theory; ClassData(typeof<TestSideEffectTaskSeq>)>]
191+
let ``TaskSeq-withCancellation with active CancellationToken applied multiple times`` variant = task {
192+
use cts = new CancellationTokenSource()
193+
let ts = Gen.getSeqWithSideEffect variant
194+
let wrapped = TaskSeq.withCancellation cts.Token ts
195+
196+
let! first = wrapped |> TaskSeq.toArrayAsync
197+
let! second = wrapped |> TaskSeq.toArrayAsync
198+
199+
first |> should equal [| 1..10 |]
200+
second |> should equal [| 11..20 |]
201+
}
202+
203+
[<Fact>]
204+
let ``TaskSeq-withCancellation evaluates each source element exactly once per iteration`` () = task {
205+
let mutable count = 0
206+
207+
let ts = taskSeq {
208+
for i in 1..5 do
209+
count <- count + 1
210+
yield i
211+
}
212+
213+
let! _ =
214+
ts
215+
|> TaskSeq.withCancellation CancellationToken.None
216+
|> TaskSeq.toArrayAsync
217+
218+
count |> should equal 5
219+
220+
let! _ =
221+
ts
222+
|> TaskSeq.withCancellation CancellationToken.None
223+
|> TaskSeq.toArrayAsync
224+
225+
count |> should equal 10
226+
}

src/FSharp.Control.TaskSeq/TaskSeq.fs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,10 @@ type TaskSeq private () =
569569
static member compareWithAsync comparer source1 source2 = Internal.compareWithAsync comparer source1 source2
570570
static member fold folder state source = Internal.fold (FolderAction folder) state source
571571
static member foldAsync folder state source = Internal.fold (AsyncFolderAction folder) state source
572+
static member foldWhile predicate folder state source = Internal.foldWhile predicate folder state source
573+
574+
static member foldWhileAsync predicate folder state source = Internal.foldWhileAsync predicate folder state source
575+
572576
static member scan folder state source = Internal.scan (FolderAction folder) state source
573577
static member scanAsync folder state source = Internal.scan (AsyncFolderAction folder) state source
574578
static member reduce folder source = Internal.reduce (FolderAction folder) source

0 commit comments

Comments
 (0)