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