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")); } }