|
| 1 | +module FSharp.Data.Tests.CsvRuntimeOperations |
| 2 | + |
| 3 | +open NUnit.Framework |
| 4 | +open FsUnit |
| 5 | +open System |
| 6 | +open System.IO |
| 7 | +open FSharp.Data |
| 8 | +open FSharp.Data.CsvExtensions |
| 9 | + |
| 10 | +let private sampleCsv = |
| 11 | + "Name,Age,City\r\nAlice,30,London\r\nBob,25,Paris\r\nCarol,35,Berlin\r\nDave,22,Tokyo\r\nEve,28,Sydney" |
| 12 | + |
| 13 | +// ============================================================ |
| 14 | +// Filter |
| 15 | +// ============================================================ |
| 16 | + |
| 17 | +[<Test>] |
| 18 | +let ``Filter keeps only rows matching the predicate`` () = |
| 19 | + let csv = CsvFile.Parse(sampleCsv) |
| 20 | + let filtered = csv.Filter(fun row -> row.["Age"].AsInteger() >= 28) |
| 21 | + filtered.Rows |> Seq.length |> should equal 3 |
| 22 | + |
| 23 | +[<Test>] |
| 24 | +let ``Filter returns empty rows when no rows match`` () = |
| 25 | + let csv = CsvFile.Parse(sampleCsv) |
| 26 | + let filtered = csv.Filter(fun row -> row.["Age"].AsInteger() > 100) |
| 27 | + filtered.Rows |> Seq.length |> should equal 0 |
| 28 | + |
| 29 | +[<Test>] |
| 30 | +let ``Filter preserves header information`` () = |
| 31 | + let csv = CsvFile.Parse(sampleCsv) |
| 32 | + let filtered = csv.Filter(fun row -> row.["City"] = "London") |
| 33 | + filtered.Headers |> should equal (Some [| "Name"; "Age"; "City" |]) |
| 34 | + |
| 35 | +[<Test>] |
| 36 | +let ``Filter returns all rows when all match`` () = |
| 37 | + let csv = CsvFile.Parse(sampleCsv) |
| 38 | + let filtered = csv.Filter(fun row -> row.["Age"].AsInteger() > 0) |
| 39 | + filtered.Rows |> Seq.length |> should equal 5 |
| 40 | + |
| 41 | +// ============================================================ |
| 42 | +// Take |
| 43 | +// ============================================================ |
| 44 | + |
| 45 | +[<Test>] |
| 46 | +let ``Take returns the first N rows`` () = |
| 47 | + let csv = CsvFile.Parse(sampleCsv) |
| 48 | + let taken = csv.Take 3 |
| 49 | + let rows = taken.Rows |> Seq.toArray |
| 50 | + rows |> should haveLength 3 |
| 51 | + rows.[0].["Name"] |> should equal "Alice" |
| 52 | + rows.[2].["Name"] |> should equal "Carol" |
| 53 | + |
| 54 | +[<Test>] |
| 55 | +let ``Take 0 returns empty rows`` () = |
| 56 | + let csv = CsvFile.Parse(sampleCsv) |
| 57 | + csv.Take(0).Rows |> Seq.length |> should equal 0 |
| 58 | + |
| 59 | +[<Test>] |
| 60 | +let ``Take preserves headers`` () = |
| 61 | + let csv = CsvFile.Parse(sampleCsv) |
| 62 | + let taken = csv.Take 2 |
| 63 | + taken.Headers |> should equal (Some [| "Name"; "Age"; "City" |]) |
| 64 | + |
| 65 | +// ============================================================ |
| 66 | +// TakeWhile |
| 67 | +// ============================================================ |
| 68 | + |
| 69 | +[<Test>] |
| 70 | +let ``TakeWhile yields rows while predicate is true`` () = |
| 71 | + let csv = CsvFile.Parse(sampleCsv) |
| 72 | + // Ages: 30, 25, 35, 22, 28 — take while Age > 26; Bob=25 fails first |
| 73 | + let taken = csv.TakeWhile(fun row -> row.["Age"].AsInteger() > 26) |
| 74 | + let rows = taken.Rows |> Seq.toArray |
| 75 | + rows |> should haveLength 1 |
| 76 | + rows.[0].["Name"] |> should equal "Alice" |
| 77 | + |
| 78 | +[<Test>] |
| 79 | +let ``TakeWhile returns all rows when predicate always true`` () = |
| 80 | + let csv = CsvFile.Parse(sampleCsv) |
| 81 | + let taken = csv.TakeWhile(fun row -> row.["Age"].AsInteger() > 0) |
| 82 | + taken.Rows |> Seq.length |> should equal 5 |
| 83 | + |
| 84 | +[<Test>] |
| 85 | +let ``TakeWhile returns empty when first row fails predicate`` () = |
| 86 | + let csv = CsvFile.Parse(sampleCsv) |
| 87 | + let taken = csv.TakeWhile(fun row -> row.["Age"].AsInteger() > 100) |
| 88 | + taken.Rows |> Seq.length |> should equal 0 |
| 89 | + |
| 90 | +// ============================================================ |
| 91 | +// Skip |
| 92 | +// ============================================================ |
| 93 | + |
| 94 | +[<Test>] |
| 95 | +let ``Skip omits the first N rows`` () = |
| 96 | + let csv = CsvFile.Parse(sampleCsv) |
| 97 | + let skipped = csv.Skip 3 |
| 98 | + let rows = skipped.Rows |> Seq.toArray |
| 99 | + rows |> should haveLength 2 |
| 100 | + rows.[0].["Name"] |> should equal "Dave" |
| 101 | + rows.[1].["Name"] |> should equal "Eve" |
| 102 | + |
| 103 | +[<Test>] |
| 104 | +let ``Skip 0 returns all rows`` () = |
| 105 | + let csv = CsvFile.Parse(sampleCsv) |
| 106 | + csv.Skip(0).Rows |> Seq.length |> should equal 5 |
| 107 | + |
| 108 | +[<Test>] |
| 109 | +let ``Skip preserves headers`` () = |
| 110 | + let csv = CsvFile.Parse(sampleCsv) |
| 111 | + csv.Skip(2).Headers |> should equal (Some [| "Name"; "Age"; "City" |]) |
| 112 | + |
| 113 | +// ============================================================ |
| 114 | +// SkipWhile |
| 115 | +// ============================================================ |
| 116 | + |
| 117 | +[<Test>] |
| 118 | +let ``SkipWhile skips rows while predicate is true then yields rest`` () = |
| 119 | + let csv = CsvFile.Parse(sampleCsv) |
| 120 | + // Ages: 30, 25, 35, 22, 28 — skip while Age >= 25 (Alice=30, Bob=25, Carol=35, then Dave=22 stops) |
| 121 | + let skipped = csv.SkipWhile(fun row -> row.["Age"].AsInteger() >= 25) |
| 122 | + let rows = skipped.Rows |> Seq.toArray |
| 123 | + rows |> should haveLength 2 |
| 124 | + rows.[0].["Name"] |> should equal "Dave" |
| 125 | + |
| 126 | +[<Test>] |
| 127 | +let ``SkipWhile returns all rows when first row fails predicate`` () = |
| 128 | + let csv = CsvFile.Parse(sampleCsv) |
| 129 | + let skipped = csv.SkipWhile(fun row -> row.["Age"].AsInteger() > 100) |
| 130 | + skipped.Rows |> Seq.length |> should equal 5 |
| 131 | + |
| 132 | +// ============================================================ |
| 133 | +// Truncate |
| 134 | +// ============================================================ |
| 135 | + |
| 136 | +[<Test>] |
| 137 | +let ``Truncate returns at most N rows`` () = |
| 138 | + let csv = CsvFile.Parse(sampleCsv) |
| 139 | + let truncated = csv.Truncate 2 |
| 140 | + truncated.Rows |> Seq.length |> should equal 2 |
| 141 | + |
| 142 | +[<Test>] |
| 143 | +let ``Truncate with count larger than row count returns all rows`` () = |
| 144 | + let csv = CsvFile.Parse(sampleCsv) |
| 145 | + let truncated = csv.Truncate 100 |
| 146 | + truncated.Rows |> Seq.length |> should equal 5 |
| 147 | + |
| 148 | +[<Test>] |
| 149 | +let ``Truncate 0 returns empty rows`` () = |
| 150 | + let csv = CsvFile.Parse(sampleCsv) |
| 151 | + csv.Truncate(0).Rows |> Seq.length |> should equal 0 |
| 152 | + |
| 153 | +// ============================================================ |
| 154 | +// Append |
| 155 | +// ============================================================ |
| 156 | + |
| 157 | +[<Test>] |
| 158 | +let ``Append adds rows from another sequence`` () = |
| 159 | + let csv = CsvFile.Parse(sampleCsv) |
| 160 | + let first2 = csv.Take 2 |
| 161 | + let last3 = csv.Skip 2 |
| 162 | + let combined = first2.Append(last3.Rows) |
| 163 | + combined.Rows |> Seq.length |> should equal 5 |
| 164 | + |
| 165 | +[<Test>] |
| 166 | +let ``Append preserves existing rows`` () = |
| 167 | + let csv = CsvFile.Parse(sampleCsv) |
| 168 | + let first1 = csv.Take 1 |
| 169 | + let extra = csv.Take 1 |
| 170 | + let combined = first1.Append(extra.Rows) |
| 171 | + let rows = combined.Rows |> Seq.toArray |
| 172 | + rows |> should haveLength 2 |
| 173 | + rows.[0].["Name"] |> should equal "Alice" |
| 174 | + rows.[1].["Name"] |> should equal "Alice" |
| 175 | + |
| 176 | +// ============================================================ |
| 177 | +// Cache |
| 178 | +// ============================================================ |
| 179 | + |
| 180 | +[<Test>] |
| 181 | +let ``Cache preserves all rows`` () = |
| 182 | + let csv = CsvFile.Parse(sampleCsv) |
| 183 | + let cached = csv.Cache() |
| 184 | + cached.Rows |> Seq.length |> should equal 5 |
| 185 | + |
| 186 | +[<Test>] |
| 187 | +let ``Cache can be enumerated multiple times`` () = |
| 188 | + let csv = CsvFile.Parse(sampleCsv) |
| 189 | + let cached = csv.Cache() |
| 190 | + cached.Rows |> Seq.length |> should equal 5 |
| 191 | + cached.Rows |> Seq.length |> should equal 5 |
| 192 | + |
| 193 | +// ============================================================ |
| 194 | +// SaveToString |
| 195 | +// ============================================================ |
| 196 | + |
| 197 | +[<Test>] |
| 198 | +let ``SaveToString produces header row and all data rows`` () = |
| 199 | + let csv = CsvFile.Parse(sampleCsv) |
| 200 | + let result = csv.SaveToString() |
| 201 | + result |> should contain "Name,Age,City" |
| 202 | + result |> should contain "Alice,30,London" |
| 203 | + result |> should contain "Eve,28,Sydney" |
| 204 | + |
| 205 | +[<Test>] |
| 206 | +let ``SaveToString round-trips CSV correctly`` () = |
| 207 | + let csv = CsvFile.Parse(sampleCsv) |
| 208 | + let serialised = csv.SaveToString() |
| 209 | + let reparsed = CsvFile.Parse(serialised) |
| 210 | + reparsed.Rows |> Seq.length |> should equal 5 |
| 211 | + reparsed.Rows |> Seq.head |> fun r -> r.["Name"] |> should equal "Alice" |
| 212 | + |
| 213 | +[<Test>] |
| 214 | +let ``SaveToString uses CRLF line endings per RFC 4180`` () = |
| 215 | + let csv = CsvFile.Parse(sampleCsv) |
| 216 | + let result = csv.SaveToString() |
| 217 | + result |> should contain "\r\n" |
| 218 | + |
| 219 | +[<Test>] |
| 220 | +let ``SaveToString quotes fields containing the separator`` () = |
| 221 | + let csv = CsvFile.Parse("Name,Note\r\nAlice,\"hello, world\"") |
| 222 | + let result = csv.SaveToString() |
| 223 | + result |> should contain "\"hello, world\"" |
| 224 | + |
| 225 | +[<Test>] |
| 226 | +let ``SaveToString with custom separator uses it in output`` () = |
| 227 | + let csv = CsvFile.Parse(sampleCsv) |
| 228 | + let result = csv.SaveToString(separator = ';') |
| 229 | + result |> should startWith "Name;Age;City" |
| 230 | + |
| 231 | +// ============================================================ |
| 232 | +// Save to TextWriter |
| 233 | +// ============================================================ |
| 234 | + |
| 235 | +[<Test>] |
| 236 | +let ``Save to TextWriter produces the same output as SaveToString`` () = |
| 237 | + let csv = CsvFile.Parse(sampleCsv) |
| 238 | + use writer = new StringWriter() |
| 239 | + csv.Save(writer) |
| 240 | + let fromSave = writer.ToString() |
| 241 | + let fromSaveToString = csv.SaveToString() |
| 242 | + fromSave |> should equal fromSaveToString |
| 243 | + |
| 244 | +// ============================================================ |
| 245 | +// Chaining operations |
| 246 | +// ============================================================ |
| 247 | + |
| 248 | +[<Test>] |
| 249 | +let ``Chaining Filter and Take works correctly`` () = |
| 250 | + let csv = CsvFile.Parse(sampleCsv) |
| 251 | + let result = csv.Filter(fun row -> row.["Age"].AsInteger() >= 28).Take(2) |
| 252 | + let rows = result.Rows |> Seq.toArray |
| 253 | + rows |> should haveLength 2 |
| 254 | + |
| 255 | +[<Test>] |
| 256 | +let ``Chaining Skip and Truncate works correctly`` () = |
| 257 | + let csv = CsvFile.Parse(sampleCsv) |
| 258 | + let result = csv.Skip(1).Truncate(2) |
| 259 | + let rows = result.Rows |> Seq.toArray |
| 260 | + rows |> should haveLength 2 |
| 261 | + rows.[0].["Name"] |> should equal "Bob" |
0 commit comments