From 4f1c1397d298a5746276985b4bc0c1d49e81da9b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 05:11:20 +0000 Subject: [PATCH 1/4] Add unit tests for CsvFile runtime transformation methods Add 30 tests in CsvRuntimeOperations.fs covering the previously untested transformation API on CsvFile<'T>: - Filter: predicate-based row filtering - Take / TakeWhile: row limiting - Skip / SkipWhile: row skipping - Truncate: at-most-N rows - Append: combining row sequences - Cache: lazy-to-eager caching - SaveToString / Save(TextWriter): serialisation round-trips, RFC 4180 CRLF handling, quoting, custom separator - Operation chaining: Filter+Take, Skip+Truncate All 3010 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CsvRuntimeOperations.fs | 261 ++++++++++++++++++ .../FSharp.Data.Core.Tests.fsproj | 1 + 2 files changed, 262 insertions(+) create mode 100644 tests/FSharp.Data.Core.Tests/CsvRuntimeOperations.fs diff --git a/tests/FSharp.Data.Core.Tests/CsvRuntimeOperations.fs b/tests/FSharp.Data.Core.Tests/CsvRuntimeOperations.fs new file mode 100644 index 000000000..9ecd8af79 --- /dev/null +++ b/tests/FSharp.Data.Core.Tests/CsvRuntimeOperations.fs @@ -0,0 +1,261 @@ +module FSharp.Data.Tests.CsvRuntimeOperations + +open NUnit.Framework +open FsUnit +open System +open System.IO +open FSharp.Data +open FSharp.Data.CsvExtensions + +let private sampleCsv = + "Name,Age,City\r\nAlice,30,London\r\nBob,25,Paris\r\nCarol,35,Berlin\r\nDave,22,Tokyo\r\nEve,28,Sydney" + +// ============================================================ +// Filter +// ============================================================ + +[] +let ``Filter keeps only rows matching the predicate`` () = + let csv = CsvFile.Parse(sampleCsv) + let filtered = csv.Filter(fun row -> row.["Age"].AsInteger() >= 28) + filtered.Rows |> Seq.length |> should equal 3 + +[] +let ``Filter returns empty rows when no rows match`` () = + let csv = CsvFile.Parse(sampleCsv) + let filtered = csv.Filter(fun row -> row.["Age"].AsInteger() > 100) + filtered.Rows |> Seq.length |> should equal 0 + +[] +let ``Filter preserves header information`` () = + let csv = CsvFile.Parse(sampleCsv) + let filtered = csv.Filter(fun row -> row.["City"] = "London") + filtered.Headers |> should equal (Some [| "Name"; "Age"; "City" |]) + +[] +let ``Filter returns all rows when all match`` () = + let csv = CsvFile.Parse(sampleCsv) + let filtered = csv.Filter(fun row -> row.["Age"].AsInteger() > 0) + filtered.Rows |> Seq.length |> should equal 5 + +// ============================================================ +// Take +// ============================================================ + +[] +let ``Take returns the first N rows`` () = + let csv = CsvFile.Parse(sampleCsv) + let taken = csv.Take 3 + let rows = taken.Rows |> Seq.toArray + rows |> should haveLength 3 + rows.[0].["Name"] |> should equal "Alice" + rows.[2].["Name"] |> should equal "Carol" + +[] +let ``Take 0 returns empty rows`` () = + let csv = CsvFile.Parse(sampleCsv) + csv.Take(0).Rows |> Seq.length |> should equal 0 + +[] +let ``Take preserves headers`` () = + let csv = CsvFile.Parse(sampleCsv) + let taken = csv.Take 2 + taken.Headers |> should equal (Some [| "Name"; "Age"; "City" |]) + +// ============================================================ +// TakeWhile +// ============================================================ + +[] +let ``TakeWhile yields rows while predicate is true`` () = + let csv = CsvFile.Parse(sampleCsv) + // Ages: 30, 25, 35, 22, 28 — take while Age > 26; Bob=25 fails first + let taken = csv.TakeWhile(fun row -> row.["Age"].AsInteger() > 26) + let rows = taken.Rows |> Seq.toArray + rows |> should haveLength 1 + rows.[0].["Name"] |> should equal "Alice" + +[] +let ``TakeWhile returns all rows when predicate always true`` () = + let csv = CsvFile.Parse(sampleCsv) + let taken = csv.TakeWhile(fun row -> row.["Age"].AsInteger() > 0) + taken.Rows |> Seq.length |> should equal 5 + +[] +let ``TakeWhile returns empty when first row fails predicate`` () = + let csv = CsvFile.Parse(sampleCsv) + let taken = csv.TakeWhile(fun row -> row.["Age"].AsInteger() > 100) + taken.Rows |> Seq.length |> should equal 0 + +// ============================================================ +// Skip +// ============================================================ + +[] +let ``Skip omits the first N rows`` () = + let csv = CsvFile.Parse(sampleCsv) + let skipped = csv.Skip 3 + let rows = skipped.Rows |> Seq.toArray + rows |> should haveLength 2 + rows.[0].["Name"] |> should equal "Dave" + rows.[1].["Name"] |> should equal "Eve" + +[] +let ``Skip 0 returns all rows`` () = + let csv = CsvFile.Parse(sampleCsv) + csv.Skip(0).Rows |> Seq.length |> should equal 5 + +[] +let ``Skip preserves headers`` () = + let csv = CsvFile.Parse(sampleCsv) + csv.Skip(2).Headers |> should equal (Some [| "Name"; "Age"; "City" |]) + +// ============================================================ +// SkipWhile +// ============================================================ + +[] +let ``SkipWhile skips rows while predicate is true then yields rest`` () = + let csv = CsvFile.Parse(sampleCsv) + // Ages: 30, 25, 35, 22, 28 — skip while Age >= 25 (Alice=30, Bob=25, Carol=35, then Dave=22 stops) + let skipped = csv.SkipWhile(fun row -> row.["Age"].AsInteger() >= 25) + let rows = skipped.Rows |> Seq.toArray + rows |> should haveLength 2 + rows.[0].["Name"] |> should equal "Dave" + +[] +let ``SkipWhile returns all rows when first row fails predicate`` () = + let csv = CsvFile.Parse(sampleCsv) + let skipped = csv.SkipWhile(fun row -> row.["Age"].AsInteger() > 100) + skipped.Rows |> Seq.length |> should equal 5 + +// ============================================================ +// Truncate +// ============================================================ + +[] +let ``Truncate returns at most N rows`` () = + let csv = CsvFile.Parse(sampleCsv) + let truncated = csv.Truncate 2 + truncated.Rows |> Seq.length |> should equal 2 + +[] +let ``Truncate with count larger than row count returns all rows`` () = + let csv = CsvFile.Parse(sampleCsv) + let truncated = csv.Truncate 100 + truncated.Rows |> Seq.length |> should equal 5 + +[] +let ``Truncate 0 returns empty rows`` () = + let csv = CsvFile.Parse(sampleCsv) + csv.Truncate(0).Rows |> Seq.length |> should equal 0 + +// ============================================================ +// Append +// ============================================================ + +[] +let ``Append adds rows from another sequence`` () = + let csv = CsvFile.Parse(sampleCsv) + let first2 = csv.Take 2 + let last3 = csv.Skip 2 + let combined = first2.Append(last3.Rows) + combined.Rows |> Seq.length |> should equal 5 + +[] +let ``Append preserves existing rows`` () = + let csv = CsvFile.Parse(sampleCsv) + let first1 = csv.Take 1 + let extra = csv.Take 1 + let combined = first1.Append(extra.Rows) + let rows = combined.Rows |> Seq.toArray + rows |> should haveLength 2 + rows.[0].["Name"] |> should equal "Alice" + rows.[1].["Name"] |> should equal "Alice" + +// ============================================================ +// Cache +// ============================================================ + +[] +let ``Cache preserves all rows`` () = + let csv = CsvFile.Parse(sampleCsv) + let cached = csv.Cache() + cached.Rows |> Seq.length |> should equal 5 + +[] +let ``Cache can be enumerated multiple times`` () = + let csv = CsvFile.Parse(sampleCsv) + let cached = csv.Cache() + cached.Rows |> Seq.length |> should equal 5 + cached.Rows |> Seq.length |> should equal 5 + +// ============================================================ +// SaveToString +// ============================================================ + +[] +let ``SaveToString produces header row and all data rows`` () = + let csv = CsvFile.Parse(sampleCsv) + let result = csv.SaveToString() + result |> should contain "Name,Age,City" + result |> should contain "Alice,30,London" + result |> should contain "Eve,28,Sydney" + +[] +let ``SaveToString round-trips CSV correctly`` () = + let csv = CsvFile.Parse(sampleCsv) + let serialised = csv.SaveToString() + let reparsed = CsvFile.Parse(serialised) + reparsed.Rows |> Seq.length |> should equal 5 + reparsed.Rows |> Seq.head |> fun r -> r.["Name"] |> should equal "Alice" + +[] +let ``SaveToString uses CRLF line endings per RFC 4180`` () = + let csv = CsvFile.Parse(sampleCsv) + let result = csv.SaveToString() + result |> should contain "\r\n" + +[] +let ``SaveToString quotes fields containing the separator`` () = + let csv = CsvFile.Parse("Name,Note\r\nAlice,\"hello, world\"") + let result = csv.SaveToString() + result |> should contain "\"hello, world\"" + +[] +let ``SaveToString with custom separator uses it in output`` () = + let csv = CsvFile.Parse(sampleCsv) + let result = csv.SaveToString(separator = ';') + result |> should startWith "Name;Age;City" + +// ============================================================ +// Save to TextWriter +// ============================================================ + +[] +let ``Save to TextWriter produces the same output as SaveToString`` () = + let csv = CsvFile.Parse(sampleCsv) + use writer = new StringWriter() + csv.Save(writer) + let fromSave = writer.ToString() + let fromSaveToString = csv.SaveToString() + fromSave |> should equal fromSaveToString + +// ============================================================ +// Chaining operations +// ============================================================ + +[] +let ``Chaining Filter and Take works correctly`` () = + let csv = CsvFile.Parse(sampleCsv) + let result = csv.Filter(fun row -> row.["Age"].AsInteger() >= 28).Take(2) + let rows = result.Rows |> Seq.toArray + rows |> should haveLength 2 + +[] +let ``Chaining Skip and Truncate works correctly`` () = + let csv = CsvFile.Parse(sampleCsv) + let result = csv.Skip(1).Truncate(2) + let rows = result.Rows |> Seq.toArray + rows |> should haveLength 2 + rows.[0].["Name"] |> should equal "Bob" diff --git a/tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj b/tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj index fee4b9d26..ba8bc5322 100644 --- a/tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj +++ b/tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj @@ -37,6 +37,7 @@ + From 78f90cdc885e91fc175cd6c92bd56c55b84be1e9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 05:11:22 +0000 Subject: [PATCH 2/4] ci: trigger checks From 958bbe8d038922d174f70f2f5acf6f908d7bead4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 05:06:32 +0000 Subject: [PATCH 3/4] fix: pin OpenTelemetry.Api >= 1.15.1 and GitHubActionsTestLogger 3.0.3 to resolve GHSA-g94r-2vxg-569j Pre-existing infrastructure failure: OpenTelemetry.Api 1.15.0 has a known vulnerability that causes the build to fail with NU1902. Pin to >= 1.15.1 (resolved in 1.15.3) to fix CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- paket.dependencies | 3 ++- paket.lock | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/paket.dependencies b/paket.dependencies index 7eccdc0ed..5a8cce897 100644 --- a/paket.dependencies +++ b/paket.dependencies @@ -55,7 +55,8 @@ group Test nuget NUnit3TestAdapter nuget FsUnit 4.2.0 nuget FsCheck 2.16.6 - nuget GitHubActionsTestLogger + nuget GitHubActionsTestLogger 3.0.3 + nuget OpenTelemetry.Api >= 1.15.1 group Benchmarks frameworks: net8.0 diff --git a/paket.lock b/paket.lock index 982d944ef..e180d2a28 100644 --- a/paket.lock +++ b/paket.lock @@ -440,7 +440,7 @@ NUGET FSharp.Core (>= 5.0.2) NETStandard.Library (>= 2.0.3) NUnit (>= 3.13.2 < 3.14) - GitHubActionsTestLogger (3.0.1) + GitHubActionsTestLogger (3.0.3) Microsoft.ApplicationInsights (3.0) Azure.Monitor.OpenTelemetry.Exporter (>= 1.6) Microsoft.Bcl.AsyncInterfaces (10.0.3) @@ -528,7 +528,7 @@ NUGET Microsoft.Extensions.Diagnostics.Abstractions (>= 8.0) Microsoft.Extensions.Logging.Configuration (>= 8.0) OpenTelemetry.Api.ProviderBuilderExtensions (>= 1.15) - OpenTelemetry.Api (1.15) + OpenTelemetry.Api (1.15.3) System.Diagnostics.DiagnosticSource (>= 10.0) OpenTelemetry.Api.ProviderBuilderExtensions (1.15) Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0) From ccf2f206f785c0e16e9c0e7f2044010f87a28e66 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 May 2026 05:06:33 +0000 Subject: [PATCH 4/4] ci: trigger checks