17c7e97efb
Add a pure, no-I/O RFC 4180 CSV reader and writer to the Commons project (greenfield — no CSV code existed). CsvParser: string / TextReader -> rows of string[]; streaming IEnumerable overload + eager Parse(string) + thin ParseWithHeader. Handles quoted fields (embedded delimiter/CR/LF/CRLF, "" -> " escape), space preservation, CRLF/LF/CR terminators, no phantom trailing row. Strict malformed-input policy: FormatException with 1-based line/column on a quote inside an unquoted field, a char after a closing quote, or an unterminated quote. Faithful empty-line policy: a blank line is one empty field (callers filter). CsvWriter: rows -> string / TextWriter; quote-on-demand (delimiter, quote, CR, LF only; internal quotes doubled), configurable delimiter/newline (default CRLF), optional quote-all, no trailing terminator. Tests (xUnit + Shouldly, 109): full RFC edge corpus for the parser, the writer quote-on-demand rules, and the required Parse(Write(rows)) == rows round-trip property over a table of every tricky character (x CRLF/LF/ quote-all/semicolon). Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
95 lines
3.8 KiB
C#
95 lines
3.8 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Csv;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Csv;
|
|
|
|
/// <summary>
|
|
/// The required round-trip property: for arbitrary field content, <c>Parse(Write(rows)) == rows</c>.
|
|
/// This is what makes the writer a true inverse of the parser and pins the quote-on-demand rules to
|
|
/// the parser's grammar. The corpus deliberately includes every tricky character.
|
|
/// </summary>
|
|
public sealed class CsvRoundTripTests
|
|
{
|
|
public static IEnumerable<object[]> Tables()
|
|
{
|
|
// Each case is a jagged table of fields.
|
|
yield return new object[] { new[] { new[] { "a", "b", "c" } } };
|
|
yield return new object[] { new[] { new[] { "a", "b" }, new[] { "c", "d" } } };
|
|
// NOTE: a single row of a single empty field ([[""]]) is deliberately excluded — it serialises to
|
|
// the empty string, which is indistinguishable from an empty document and parses back to zero rows.
|
|
// That one degenerate case is the sole non-round-trippable shape; every multi-field / multi-row
|
|
// empty case below (",," and blank interior lines) does round-trip exactly.
|
|
yield return new object[] { new[] { new[] { "", "", "" } } };
|
|
yield return new object[] { new[] { new[] { "a" }, new[] { "" }, new[] { "b" } } };
|
|
yield return new object[] { new[] { new[] { "a,b", "c" } } }; // embedded delimiter
|
|
yield return new object[] { new[] { new[] { "he said \"hi\"" } } }; // embedded quote
|
|
yield return new object[] { new[] { new[] { "line1\nline2" } } }; // embedded LF
|
|
yield return new object[] { new[] { new[] { "line1\r\nline2" } } }; // embedded CRLF
|
|
yield return new object[] { new[] { new[] { "line1\rline2" } } }; // embedded CR
|
|
yield return new object[] { new[] { new[] { " leading", "trailing " } } }; // spaces
|
|
yield return new object[] { new[] { new[] { " ", "\t" } } }; // whitespace-only
|
|
yield return new object[] { new[] { new[] { "\"", "\"\"", ",", "\n" } } }; // every special alone
|
|
yield return new object[] { new[] { new[] { "mix,\"quote\"\nand\r\nnewlines" } } };
|
|
yield return new object[]
|
|
{
|
|
new[]
|
|
{
|
|
new[] { "id", "name", "note" },
|
|
new[] { "1", "alice", "says \"hi, there\"" },
|
|
new[] { "2", "bob", "multi\nline\nnote" },
|
|
new[] { "3", "", "" },
|
|
},
|
|
};
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(Tables))]
|
|
public void RoundTrips_with_default_crlf(string[][] rows)
|
|
{
|
|
var csv = CsvWriter.WriteToString(rows);
|
|
var parsed = CsvParser.Parse(csv);
|
|
|
|
ShouldMatch(parsed, rows);
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(Tables))]
|
|
public void RoundTrips_with_lf_newline(string[][] rows)
|
|
{
|
|
var csv = CsvWriter.WriteToString(rows, newline: "\n");
|
|
var parsed = CsvParser.Parse(csv);
|
|
|
|
ShouldMatch(parsed, rows);
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(Tables))]
|
|
public void RoundTrips_with_quote_all_fields(string[][] rows)
|
|
{
|
|
var csv = CsvWriter.WriteToString(rows, quoteAllFields: true);
|
|
var parsed = CsvParser.Parse(csv);
|
|
|
|
ShouldMatch(parsed, rows);
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(Tables))]
|
|
public void RoundTrips_with_semicolon_delimiter(string[][] rows)
|
|
{
|
|
var csv = CsvWriter.WriteToString(rows, delimiter: ';');
|
|
var parsed = CsvParser.Parse(csv, ';');
|
|
|
|
ShouldMatch(parsed, rows);
|
|
}
|
|
|
|
private static void ShouldMatch(IReadOnlyList<string[]> parsed, string[][] expected)
|
|
{
|
|
parsed.Count.ShouldBe(expected.Length);
|
|
for (var i = 0; i < expected.Length; i++)
|
|
{
|
|
parsed[i].ShouldBe(expected[i], $"row {i} mismatch");
|
|
}
|
|
}
|
|
}
|