diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvParser.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvParser.cs new file mode 100644 index 00000000..1eefc2f0 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvParser.cs @@ -0,0 +1,293 @@ +using System.Globalization; +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Csv; + +/// +/// A pure, allocation-lean RFC 4180 CSV reader with no file/stream I/O of its own — it consumes a +/// or a and yields rows of fields. This is the single +/// CSV-reading authority in the tree; keep the RFC contract pinned here (and mirrored in +/// ) rather than re-deriving it at each call site. +/// +/// +/// Faithful to RFC 4180. The grammar handled: +/// +/// Fields are separated by the delimiter (default ','); records are separated by +/// newlines. +/// A field may be quoted with double-quotes ("). A quoted field may contain the +/// delimiter, CR, LF, and CRLF literally, and represents a literal double-quote by doubling it +/// ("""). +/// An unquoted field runs literally up to the next delimiter or newline; leading and +/// trailing spaces are preserved (RFC 4180 §2.4 — spaces are part of a field). +/// CRLF, bare LF, and bare CR are all accepted as record terminators. +/// +/// Empty-line policy. Faithful to the RFC: a line with no characters yields a single row +/// containing one empty field ([""]); it is NOT silently dropped. Callers that want blank lines +/// skipped should filter the result (e.g. rows.Where(r => r.Length > 1 || r[0].Length > 0)). +/// A trailing record terminator at end-of-input does NOT produce a phantom empty final row, and its +/// absence does not lose the last row. +/// Malformed-input policy. This parser is strict. It throws +/// (carrying a 1-based line/column position) for the malformed +/// shapes RFC 4180 forbids: (1) a bare double-quote inside an otherwise-unquoted field (e.g. +/// ab"c), (2) a stray character after a closing quote other than the delimiter or a newline +/// (e.g. "ab"c), and (3) an unterminated quoted field at end-of-input. Strictness is deliberate +/// — silent lenient recovery hides data-shape bugs in imported files. +/// +public static class CsvParser +{ + private const char Quote = '"'; + private const char Cr = '\r'; + private const char Lf = '\n'; + + /// + /// Parses fully into rows of fields. Convenience wrapper over + /// ; materialises the whole document. + /// + /// The CSV document. null is treated as empty. + /// The field separator (default comma). + /// The rows, each an array of field values. Empty input yields zero rows. + /// The input violates the strict RFC 4180 grammar. + public static IReadOnlyList Parse(string? text, char delimiter = ',') + { + using var reader = new StringReader(text ?? string.Empty); + var rows = new List(); + foreach (var row in Parse(reader, delimiter)) + { + rows.Add(row); + } + + return rows; + } + + /// + /// Streams rows from lazily — a single forward pass, one row + /// materialised at a time. The reader is not disposed by this method. + /// + /// The source. Read to end-of-input. + /// The field separator (default comma). + /// A lazily-evaluated sequence of rows; each row is a freshly allocated field array. + /// is null. + /// is a quote, CR, or LF. + /// The input violates the strict RFC 4180 grammar. + public static IEnumerable Parse(TextReader reader, char delimiter = ',') + { + ArgumentNullException.ThrowIfNull(reader); + if (delimiter is Quote or Cr or Lf) + { + throw new ArgumentException("Delimiter must not be a double-quote, CR, or LF.", nameof(delimiter)); + } + + return Iterate(reader, delimiter); + } + + private static IEnumerable Iterate(TextReader reader, char delimiter) + { + var field = new StringBuilder(); + var row = new List(); + + // True once the current row has produced any field boundary or content — i.e. once we've seen a + // char that commits us to emitting at least one field. Reset to false right after a record + // terminator closes a row, so a trailing newline at EOF does NOT synthesise a phantom row. + var rowOpen = false; + + // 1-based cursor, maintained for FormatException messages. + var line = 1; + var col = 0; + + int read; + while ((read = reader.Read()) != -1) + { + var c = (char)read; + col++; + + if (c == Quote) + { + if (field.Length != 0) + { + throw new FormatException( + $"Unexpected double-quote inside an unquoted field at line {line}, column {col}. " + + "A field is quoted only when the quote is its first character."); + } + + rowOpen = true; + + // Consume the quoted body; the opening quote has been read. + ReadQuotedField(reader, field, ref line, ref col); + + // A closing quote must be followed by the delimiter, a newline, or EOF. + var next = reader.Peek(); + if (next == -1) + { + row.Add(field.ToString()); + field.Clear(); + yield return row.ToArray(); + row.Clear(); + rowOpen = false; + yield break; + } + + var nc = (char)next; + if (nc == delimiter) + { + reader.Read(); + col++; + row.Add(field.ToString()); + field.Clear(); + continue; + } + + if (nc is Cr or Lf) + { + row.Add(field.ToString()); + field.Clear(); + ConsumeNewline(reader, ref line, ref col); + yield return row.ToArray(); + row.Clear(); + rowOpen = false; + continue; + } + + throw new FormatException( + $"Unexpected character '{nc}' after closing quote at line {line}, column {col + 1}. " + + "A quoted field must be followed by a delimiter, a newline, or end-of-input."); + } + + if (c == delimiter) + { + row.Add(field.ToString()); + field.Clear(); + rowOpen = true; + continue; + } + + if (c is Cr or Lf) + { + row.Add(field.ToString()); + field.Clear(); + if (c == Cr && reader.Peek() == Lf) + { + reader.Read(); + } + + line++; + col = 0; + yield return row.ToArray(); + row.Clear(); + rowOpen = false; + continue; + } + + field.Append(c); + rowOpen = true; + } + + // EOF: emit a trailing row only if content is pending. A clean terminator already cleared rowOpen. + if (rowOpen || field.Length != 0 || row.Count != 0) + { + row.Add(field.ToString()); + yield return row.ToArray(); + } + } + + /// + /// Reads the body of a quoted field into . The opening quote has already + /// been consumed. On return the reader sits immediately after the closing quote. + /// + private static void ReadQuotedField(TextReader reader, StringBuilder field, ref int line, ref int col) + { + var openLine = line; + var openCol = col; + + int read; + while ((read = reader.Read()) != -1) + { + var c = (char)read; + col++; + + if (c == Quote) + { + if (reader.Peek() == Quote) + { + reader.Read(); + col++; + field.Append(Quote); + continue; + } + + return; // closing quote + } + + if (c == Lf) + { + line++; + col = 0; + } + + field.Append(c); + } + + throw new FormatException( + $"Unterminated quoted field opened at line {openLine}, column {openCol}: reached end-of-input " + + "before the closing double-quote."); + } + + /// Consumes a newline (CRLF, CR, or LF) whose first character has been peeked but not read. + private static void ConsumeNewline(TextReader reader, ref int line, ref int col) + { + var first = reader.Read(); + if (first == Cr && reader.Peek() == Lf) + { + reader.Read(); + } + + line++; + col = 0; + } + + /// + /// Header-aware convenience: parses and maps every subsequent row onto the + /// first (header) row's field names. Thin wrapper over . + /// + /// The CSV document; the first row is treated as the header. + /// The field separator (default comma). + /// + /// One dictionary per data row, keyed by header name (ordinal, case-sensitive). A row shorter than + /// the header maps only the columns present; a column beyond the header's width is keyed by its + /// 0-based index rendered as a string. An empty document yields zero rows. + /// + /// The input violates the strict RFC 4180 grammar, or the header contains a duplicate column name. + public static IReadOnlyList> ParseWithHeader(string? text, char delimiter = ',') + { + var rows = Parse(text, delimiter); + if (rows.Count == 0) + { + return Array.Empty>(); + } + + var header = rows[0]; + var seen = new HashSet(StringComparer.Ordinal); + foreach (var name in header) + { + if (!seen.Add(name)) + { + throw new FormatException($"Duplicate header column name '{name}'."); + } + } + + var result = new List>(rows.Count - 1); + for (var i = 1; i < rows.Count; i++) + { + var cells = rows[i]; + var map = new Dictionary(cells.Length, StringComparer.Ordinal); + for (var c = 0; c < cells.Length; c++) + { + var key = c < header.Length ? header[c] : c.ToString(CultureInfo.InvariantCulture); + map[key] = cells[c]; + } + + result.Add(map); + } + + return result; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvWriter.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvWriter.cs new file mode 100644 index 00000000..2e654c74 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvWriter.cs @@ -0,0 +1,142 @@ +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Csv; + +/// +/// A pure RFC 4180 CSV writer with no file/stream I/O of its own — it renders rows of fields to a +/// or a . The inverse of : +/// CsvParser.Parse(CsvWriter.WriteToString(rows)) reproduces rows exactly for any field +/// content. +/// +/// +/// Quote-on-demand. A field is wrapped in double-quotes only when it must be — i.e. when +/// it contains the delimiter, a double-quote, CR, or LF — and internal double-quotes are doubled +/// ("""). Fields that need no quoting are emitted verbatim, so ordinary values stay +/// human-readable. Pass quoteAllFields: true to force every field quoted. +/// Newline. The record terminator defaults to CRLF (\r\n) per RFC 4180 and is +/// configurable. No terminator is written after the final row (matching the parser's +/// no-phantom-trailing-row contract, so a round-trip is exact). +/// +public static class CsvWriter +{ + private const char Quote = '"'; + private const char Cr = '\r'; + private const char Lf = '\n'; + + /// The RFC 4180 record terminator, "\r\n". The default newline for every write. + public const string Crlf = "\r\n"; + + /// + /// Renders to a CSV string. + /// + /// The rows to write; each inner sequence is one record's fields. A null field is written as empty. + /// The field separator (default comma). + /// The record terminator between rows (default CRLF). Not appended after the last row. + /// When true, every field is quoted regardless of content. + /// The CSV text. An empty yields the empty string. + /// is null. + /// is a quote, CR, or LF. + public static string WriteToString( + IEnumerable> rows, + char delimiter = ',', + string newline = Crlf, + bool quoteAllFields = false) + { + var sb = new StringBuilder(); + using var writer = new StringWriter(sb); + Write(writer, rows, delimiter, newline, quoteAllFields); + return sb.ToString(); + } + + /// + /// Writes to . The writer is not disposed or + /// flushed by this method. + /// + /// The destination. + /// The rows to write; each inner sequence is one record's fields. A null field is written as empty. + /// The field separator (default comma). + /// The record terminator between rows (default CRLF). Not appended after the last row. + /// When true, every field is quoted regardless of content. + /// or is null. + /// is a quote, CR, or LF. + public static void Write( + TextWriter writer, + IEnumerable> rows, + char delimiter = ',', + string newline = Crlf, + bool quoteAllFields = false) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(rows); + if (delimiter is Quote or Cr or Lf) + { + throw new ArgumentException("Delimiter must not be a double-quote, CR, or LF.", nameof(delimiter)); + } + + var firstRow = true; + foreach (var row in rows) + { + if (!firstRow) + { + writer.Write(newline); + } + + firstRow = false; + + WriteRow(writer, row, delimiter, quoteAllFields); + } + } + + private static void WriteRow(TextWriter writer, IEnumerable row, char delimiter, bool quoteAllFields) + { + ArgumentNullException.ThrowIfNull(row); + + var firstField = true; + foreach (var field in row) + { + if (!firstField) + { + writer.Write(delimiter); + } + + firstField = false; + + WriteField(writer, field ?? string.Empty, delimiter, quoteAllFields); + } + } + + private static void WriteField(TextWriter writer, string field, char delimiter, bool quoteAllFields) + { + if (!quoteAllFields && !NeedsQuoting(field, delimiter)) + { + writer.Write(field); + return; + } + + writer.Write(Quote); + foreach (var ch in field) + { + if (ch == Quote) + { + writer.Write(Quote); // double an internal quote + } + + writer.Write(ch); + } + + writer.Write(Quote); + } + + private static bool NeedsQuoting(string field, char delimiter) + { + foreach (var ch in field) + { + if (ch == delimiter || ch == Quote || ch == Cr || ch == Lf) + { + return true; + } + } + + return false; + } +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvParserTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvParserTests.cs new file mode 100644 index 00000000..a657f536 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvParserTests.cs @@ -0,0 +1,292 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Csv; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Csv; + +/// +/// The authoritative RFC 4180 read corpus for . Every edge the RFC pins — +/// quoting, embedded delimiters/newlines, escaped quotes, space preservation, empty vs. absent +/// fields, trailing-newline handling, CRLF/LF/CR terminators — plus this parser's declared +/// strict malformed-input and faithful empty-line policies, lives here. +/// +public sealed class CsvParserTests +{ + // ---- simple rows ---- + + [Fact] + public void Parses_simple_rows() + { + var rows = CsvParser.Parse("a,b,c\nd,e,f"); + + rows.Count.ShouldBe(2); + rows[0].ShouldBe(new[] { "a", "b", "c" }); + rows[1].ShouldBe(new[] { "d", "e", "f" }); + } + + [Fact] + public void Single_field_single_row() + { + CsvParser.Parse("hello").ShouldBe(new[] { new[] { "hello" } }); + } + + [Fact] + public void Empty_input_yields_no_rows() + { + CsvParser.Parse("").Count.ShouldBe(0); + CsvParser.Parse((string?)null).Count.ShouldBe(0); + } + + // ---- empty vs. absent fields ---- + + [Fact] + public void Middle_empty_field_is_preserved() + { + CsvParser.Parse("a,,c").Single().ShouldBe(new[] { "a", "", "c" }); + } + + [Fact] + public void Trailing_comma_yields_trailing_empty_field() + { + CsvParser.Parse("a,").Single().ShouldBe(new[] { "a", "" }); + } + + [Fact] + public void Leading_comma_yields_leading_empty_field() + { + CsvParser.Parse(",a").Single().ShouldBe(new[] { "", "a" }); + } + + [Fact] + public void All_empty_fields() + { + CsvParser.Parse(",,").Single().ShouldBe(new[] { "", "", "" }); + } + + // ---- quoting ---- + + [Fact] + public void Quoted_field_with_embedded_comma() + { + CsvParser.Parse("\"a,b\",c").Single().ShouldBe(new[] { "a,b", "c" }); + } + + [Fact] + public void Quoted_field_with_embedded_lf() + { + CsvParser.Parse("\"line1\nline2\",b").Single().ShouldBe(new[] { "line1\nline2", "b" }); + } + + [Fact] + public void Quoted_field_with_embedded_crlf() + { + CsvParser.Parse("\"line1\r\nline2\",b").Single().ShouldBe(new[] { "line1\r\nline2", "b" }); + } + + [Fact] + public void Escaped_quote_becomes_single_quote() + { + // "She said ""hi""" -> She said "hi" + CsvParser.Parse("\"She said \"\"hi\"\"\"").Single().ShouldBe(new[] { "She said \"hi\"" }); + } + + [Fact] + public void Empty_quoted_field_is_empty_string() + { + CsvParser.Parse("\"\",\"\"").Single().ShouldBe(new[] { "", "" }); + } + + [Fact] + public void Quoted_field_containing_only_escaped_quotes() + { + // """" -> a single literal double-quote + CsvParser.Parse("\"\"\"\"").Single().ShouldBe(new[] { "\"" }); + } + + [Fact] + public void Quoted_field_at_end_of_row_then_more_rows() + { + var rows = CsvParser.Parse("\"a,b\"\nc,d"); + rows.Count.ShouldBe(2); + rows[0].ShouldBe(new[] { "a,b" }); + rows[1].ShouldBe(new[] { "c", "d" }); + } + + // ---- space preservation (RFC 4180 §2.4) ---- + + [Fact] + public void Spaces_outside_quotes_are_preserved() + { + CsvParser.Parse(" a , b ").Single().ShouldBe(new[] { " a ", " b " }); + } + + [Fact] + public void Spaces_inside_quotes_are_preserved() + { + CsvParser.Parse("\" padded \"").Single().ShouldBe(new[] { " padded " }); + } + + // ---- line endings ---- + + [Fact] + public void Lf_line_endings() + { + CsvParser.Parse("a\nb\nc").Select(r => r[0]).ShouldBe(new[] { "a", "b", "c" }); + } + + [Fact] + public void Crlf_line_endings() + { + CsvParser.Parse("a\r\nb\r\nc").Select(r => r[0]).ShouldBe(new[] { "a", "b", "c" }); + } + + [Fact] + public void Bare_cr_line_endings() + { + CsvParser.Parse("a\rb\rc").Select(r => r[0]).ShouldBe(new[] { "a", "b", "c" }); + } + + // ---- trailing newline: present vs. absent ---- + + [Fact] + public void Trailing_lf_does_not_add_phantom_row() + { + CsvParser.Parse("a,b\n").Count.ShouldBe(1); + CsvParser.Parse("a,b\n").Single().ShouldBe(new[] { "a", "b" }); + } + + [Fact] + public void Trailing_crlf_does_not_add_phantom_row() + { + CsvParser.Parse("a,b\r\n").Count.ShouldBe(1); + } + + [Fact] + public void No_trailing_newline_keeps_last_row() + { + var withNl = CsvParser.Parse("a\nb\n"); + var withoutNl = CsvParser.Parse("a\nb"); + withNl.Count.ShouldBe(2); + withoutNl.Count.ShouldBe(2); + withNl[1].ShouldBe(withoutNl[1]); + } + + [Fact] + public void Quoted_field_with_trailing_newline_no_phantom_row() + { + var rows = CsvParser.Parse("\"a\"\n"); + rows.Count.ShouldBe(1); + rows[0].ShouldBe(new[] { "a" }); + } + + // ---- empty-line policy (faithful RFC: empty line == one empty field) ---- + + [Fact] + public void Empty_line_is_a_single_empty_field() + { + // a\n\nb -> ["a"], [""], ["b"] — the blank line is NOT dropped. + var rows = CsvParser.Parse("a\n\nb"); + rows.Count.ShouldBe(3); + rows[0].ShouldBe(new[] { "a" }); + rows[1].ShouldBe(new[] { "" }); + rows[2].ShouldBe(new[] { "b" }); + } + + [Fact] + public void Callers_can_filter_blank_lines() + { + var rows = CsvParser.Parse("a\n\nb") + .Where(r => r.Length > 1 || r[0].Length > 0) + .ToList(); + rows.Count.ShouldBe(2); + } + + // ---- streaming overload ---- + + [Fact] + public void Streams_over_a_textreader() + { + using var reader = new StringReader("x,y\nz,w"); + var rows = CsvParser.Parse(reader).ToList(); + rows.Count.ShouldBe(2); + rows[1].ShouldBe(new[] { "z", "w" }); + } + + [Fact] + public void Custom_delimiter_semicolon() + { + CsvParser.Parse("a;b;c", ';').Single().ShouldBe(new[] { "a", "b", "c" }); + } + + [Fact] + public void Custom_delimiter_leaves_comma_literal() + { + CsvParser.Parse("a,b;c", ';').Single().ShouldBe(new[] { "a,b", "c" }); + } + + // ---- strict malformed-input policy ---- + + [Fact] + public void Unterminated_quote_throws() + { + var ex = Should.Throw(() => CsvParser.Parse("\"unclosed")); + ex.Message.ShouldContain("Unterminated"); + } + + [Fact] + public void Quote_inside_unquoted_field_throws() + { + var ex = Should.Throw(() => CsvParser.Parse("ab\"c")); + ex.Message.ShouldContain("unquoted"); + } + + [Fact] + public void Character_after_closing_quote_throws() + { + var ex = Should.Throw(() => CsvParser.Parse("\"ab\"c")); + ex.Message.ShouldContain("after closing quote"); + } + + [Fact] + public void Malformed_message_carries_position() + { + var ex = Should.Throw(() => CsvParser.Parse("a,b\nab\"c")); + ex.Message.ShouldContain("line 2"); + } + + [Fact] + public void Delimiter_may_not_be_a_quote() + { + Should.Throw(() => CsvParser.Parse("a,b", '"').ToString()); + } + + // ---- ParseWithHeader ---- + + [Fact] + public void ParseWithHeader_maps_rows_by_name() + { + var rows = CsvParser.ParseWithHeader("name,age\nalice,30\nbob,40"); + rows.Count.ShouldBe(2); + rows[0]["name"].ShouldBe("alice"); + rows[0]["age"].ShouldBe("30"); + rows[1]["name"].ShouldBe("bob"); + } + + [Fact] + public void ParseWithHeader_empty_document_yields_no_rows() + { + CsvParser.ParseWithHeader("").Count.ShouldBe(0); + } + + [Fact] + public void ParseWithHeader_header_only_yields_no_rows() + { + CsvParser.ParseWithHeader("name,age").Count.ShouldBe(0); + } + + [Fact] + public void ParseWithHeader_duplicate_header_throws() + { + Should.Throw(() => CsvParser.ParseWithHeader("name,name\nx,y")); + } +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvRoundTripTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvRoundTripTests.cs new file mode 100644 index 00000000..f2521798 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvRoundTripTests.cs @@ -0,0 +1,94 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Csv; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Csv; + +/// +/// The required round-trip property: for arbitrary field content, Parse(Write(rows)) == rows. +/// 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. +/// +public sealed class CsvRoundTripTests +{ + public static IEnumerable 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 parsed, string[][] expected) + { + parsed.Count.ShouldBe(expected.Length); + for (var i = 0; i < expected.Length; i++) + { + parsed[i].ShouldBe(expected[i], $"row {i} mismatch"); + } + } +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvWriterTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvWriterTests.cs new file mode 100644 index 00000000..0f1ae510 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvWriterTests.cs @@ -0,0 +1,126 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Csv; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Csv; + +/// +/// Quote-on-demand and terminator behaviour for : a field is quoted only when +/// it must be (delimiter / quote / CR / LF), internal quotes double, CRLF terminates records by +/// default (configurable), and no terminator trails the final row. +/// +public sealed class CsvWriterTests +{ + [Fact] + public void Writes_simple_rows_with_crlf_default() + { + var csv = CsvWriter.WriteToString(new[] + { + new[] { "a", "b", "c" }, + new[] { "d", "e", "f" }, + }); + + csv.ShouldBe("a,b,c\r\nd,e,f"); + } + + [Fact] + public void No_trailing_newline_after_last_row() + { + CsvWriter.WriteToString(new[] { new[] { "a" } }).ShouldBe("a"); + } + + [Fact] + public void Empty_rows_yield_empty_string() + { + CsvWriter.WriteToString(Array.Empty()).ShouldBe(""); + } + + // ---- quote-on-demand ---- + + [Fact] + public void Plain_field_is_not_quoted() + { + CsvWriter.WriteToString(new[] { new[] { "plain" } }).ShouldBe("plain"); + } + + [Fact] + public void Field_with_comma_is_quoted() + { + CsvWriter.WriteToString(new[] { new[] { "a,b" } }).ShouldBe("\"a,b\""); + } + + [Fact] + public void Field_with_quote_is_quoted_and_doubled() + { + CsvWriter.WriteToString(new[] { new[] { "a\"b" } }).ShouldBe("\"a\"\"b\""); + } + + [Fact] + public void Field_with_lf_is_quoted() + { + CsvWriter.WriteToString(new[] { new[] { "a\nb" } }).ShouldBe("\"a\nb\""); + } + + [Fact] + public void Field_with_cr_is_quoted() + { + CsvWriter.WriteToString(new[] { new[] { "a\rb" } }).ShouldBe("\"a\rb\""); + } + + [Fact] + public void Leading_and_trailing_spaces_are_not_quoted() + { + // RFC does not require quoting for spaces; the parser preserves them either way. + CsvWriter.WriteToString(new[] { new[] { " a " } }).ShouldBe(" a "); + } + + [Fact] + public void Empty_fields_are_written_bare() + { + CsvWriter.WriteToString(new[] { new[] { "a", "", "c" } }).ShouldBe("a,,c"); + } + + [Fact] + public void Null_field_is_written_as_empty() + { + CsvWriter.WriteToString(new[] { new[] { "a", null, "c" } }).ShouldBe("a,,c"); + } + + // ---- options ---- + + [Fact] + public void QuoteAllFields_forces_every_field_quoted() + { + CsvWriter.WriteToString(new[] { new[] { "a", "b" } }, quoteAllFields: true) + .ShouldBe("\"a\",\"b\""); + } + + [Fact] + public void Custom_newline_lf() + { + CsvWriter.WriteToString(new[] { new[] { "a" }, new[] { "b" } }, newline: "\n") + .ShouldBe("a\nb"); + } + + [Fact] + public void Custom_delimiter_semicolon_quotes_on_semicolon_not_comma() + { + CsvWriter.WriteToString(new[] { new[] { "a,b", "c;d" } }, delimiter: ';') + .ShouldBe("a,b;\"c;d\""); + } + + [Fact] + public void Writes_to_a_textwriter() + { + var sw = new StringWriter(); + CsvWriter.Write(sw, new[] { new[] { "x", "y" } }); + sw.ToString().ShouldBe("x,y"); + } + + [Fact] + public void Delimiter_may_not_be_a_quote() + { + Should.Throw(() => + CsvWriter.WriteToString(new[] { new[] { "a" } }, delimiter: '"')); + } +}