feat(commons): T0-2 RFC-4180 CSV parser + writer
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
This commit is contained in:
@@ -0,0 +1,293 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Csv;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A pure, allocation-lean RFC 4180 CSV reader with no file/stream I/O of its own — it consumes a
|
||||||
|
/// <see cref="string"/> or a <see cref="TextReader"/> and yields rows of fields. This is the single
|
||||||
|
/// CSV-reading authority in the tree; keep the RFC contract pinned here (and mirrored in
|
||||||
|
/// <see cref="CsvWriter"/>) rather than re-deriving it at each call site.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>Faithful to RFC 4180.</b> The grammar handled:</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Fields are separated by the delimiter (default <c>','</c>); records are separated by
|
||||||
|
/// newlines.</item>
|
||||||
|
/// <item>A field may be quoted with double-quotes (<c>"</c>). A quoted field may contain the
|
||||||
|
/// delimiter, CR, LF, and CRLF literally, and represents a literal double-quote by doubling it
|
||||||
|
/// (<c>""</c> → <c>"</c>).</item>
|
||||||
|
/// <item>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).</item>
|
||||||
|
/// <item>CRLF, bare LF, and bare CR are all accepted as record terminators.</item>
|
||||||
|
/// </list>
|
||||||
|
/// <para><b>Empty-line policy.</b> Faithful to the RFC: a line with no characters yields a single row
|
||||||
|
/// containing one empty field (<c>[""]</c>); it is NOT silently dropped. Callers that want blank lines
|
||||||
|
/// skipped should filter the result (e.g. <c>rows.Where(r => r.Length > 1 || r[0].Length > 0)</c>).
|
||||||
|
/// 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.</para>
|
||||||
|
/// <para><b>Malformed-input policy.</b> This parser is <b>strict</b>. It throws
|
||||||
|
/// <see cref="System.FormatException"/> (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.
|
||||||
|
/// <c>ab"c</c>), (2) a stray character after a closing quote other than the delimiter or a newline
|
||||||
|
/// (e.g. <c>"ab"c</c>), and (3) an unterminated quoted field at end-of-input. Strictness is deliberate
|
||||||
|
/// — silent lenient recovery hides data-shape bugs in imported files.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class CsvParser
|
||||||
|
{
|
||||||
|
private const char Quote = '"';
|
||||||
|
private const char Cr = '\r';
|
||||||
|
private const char Lf = '\n';
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses <paramref name="text"/> fully into rows of fields. Convenience wrapper over
|
||||||
|
/// <see cref="Parse(TextReader,char)"/>; materialises the whole document.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="text">The CSV document. <c>null</c> is treated as empty.</param>
|
||||||
|
/// <param name="delimiter">The field separator (default comma).</param>
|
||||||
|
/// <returns>The rows, each an array of field values. Empty input yields zero rows.</returns>
|
||||||
|
/// <exception cref="System.FormatException">The input violates the strict RFC 4180 grammar.</exception>
|
||||||
|
public static IReadOnlyList<string[]> Parse(string? text, char delimiter = ',')
|
||||||
|
{
|
||||||
|
using var reader = new StringReader(text ?? string.Empty);
|
||||||
|
var rows = new List<string[]>();
|
||||||
|
foreach (var row in Parse(reader, delimiter))
|
||||||
|
{
|
||||||
|
rows.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams rows from <paramref name="reader"/> lazily — a single forward pass, one row
|
||||||
|
/// materialised at a time. The reader is not disposed by this method.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="reader">The source. Read to end-of-input.</param>
|
||||||
|
/// <param name="delimiter">The field separator (default comma).</param>
|
||||||
|
/// <returns>A lazily-evaluated sequence of rows; each row is a freshly allocated field array.</returns>
|
||||||
|
/// <exception cref="System.ArgumentNullException"><paramref name="reader"/> is <c>null</c>.</exception>
|
||||||
|
/// <exception cref="System.ArgumentException"><paramref name="delimiter"/> is a quote, CR, or LF.</exception>
|
||||||
|
/// <exception cref="System.FormatException">The input violates the strict RFC 4180 grammar.</exception>
|
||||||
|
public static IEnumerable<string[]> 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<string[]> Iterate(TextReader reader, char delimiter)
|
||||||
|
{
|
||||||
|
var field = new StringBuilder();
|
||||||
|
var row = new List<string>();
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the body of a quoted field into <paramref name="field"/>. The opening quote has already
|
||||||
|
/// been consumed. On return the reader sits immediately after the closing quote.
|
||||||
|
/// </summary>
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Consumes a newline (CRLF, CR, or LF) whose first character has been peeked but not read.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Header-aware convenience: parses <paramref name="text"/> and maps every subsequent row onto the
|
||||||
|
/// first (header) row's field names. Thin wrapper over <see cref="Parse(string,char)"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="text">The CSV document; the first row is treated as the header.</param>
|
||||||
|
/// <param name="delimiter">The field separator (default comma).</param>
|
||||||
|
/// <returns>
|
||||||
|
/// 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.
|
||||||
|
/// </returns>
|
||||||
|
/// <exception cref="System.FormatException">The input violates the strict RFC 4180 grammar, or the header contains a duplicate column name.</exception>
|
||||||
|
public static IReadOnlyList<IReadOnlyDictionary<string, string>> ParseWithHeader(string? text, char delimiter = ',')
|
||||||
|
{
|
||||||
|
var rows = Parse(text, delimiter);
|
||||||
|
if (rows.Count == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<IReadOnlyDictionary<string, string>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var header = rows[0];
|
||||||
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var name in header)
|
||||||
|
{
|
||||||
|
if (!seen.Add(name))
|
||||||
|
{
|
||||||
|
throw new FormatException($"Duplicate header column name '{name}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<IReadOnlyDictionary<string, string>>(rows.Count - 1);
|
||||||
|
for (var i = 1; i < rows.Count; i++)
|
||||||
|
{
|
||||||
|
var cells = rows[i];
|
||||||
|
var map = new Dictionary<string, string>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Csv;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A pure RFC 4180 CSV writer with no file/stream I/O of its own — it renders rows of fields to a
|
||||||
|
/// <see cref="string"/> or a <see cref="TextWriter"/>. The inverse of <see cref="CsvParser"/>:
|
||||||
|
/// <c>CsvParser.Parse(CsvWriter.WriteToString(rows))</c> reproduces <c>rows</c> exactly for any field
|
||||||
|
/// content.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>Quote-on-demand.</b> 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
|
||||||
|
/// (<c>"</c> → <c>""</c>). Fields that need no quoting are emitted verbatim, so ordinary values stay
|
||||||
|
/// human-readable. Pass <c>quoteAllFields: true</c> to force every field quoted.</para>
|
||||||
|
/// <para><b>Newline.</b> The record terminator defaults to CRLF (<c>\r\n</c>) 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).</para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class CsvWriter
|
||||||
|
{
|
||||||
|
private const char Quote = '"';
|
||||||
|
private const char Cr = '\r';
|
||||||
|
private const char Lf = '\n';
|
||||||
|
|
||||||
|
/// <summary>The RFC 4180 record terminator, <c>"\r\n"</c>. The default <c>newline</c> for every write.</summary>
|
||||||
|
public const string Crlf = "\r\n";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders <paramref name="rows"/> to a CSV string.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="rows">The rows to write; each inner sequence is one record's fields. A <c>null</c> field is written as empty.</param>
|
||||||
|
/// <param name="delimiter">The field separator (default comma).</param>
|
||||||
|
/// <param name="newline">The record terminator between rows (default CRLF). Not appended after the last row.</param>
|
||||||
|
/// <param name="quoteAllFields">When <c>true</c>, every field is quoted regardless of content.</param>
|
||||||
|
/// <returns>The CSV text. An empty <paramref name="rows"/> yields the empty string.</returns>
|
||||||
|
/// <exception cref="System.ArgumentNullException"><paramref name="rows"/> is <c>null</c>.</exception>
|
||||||
|
/// <exception cref="System.ArgumentException"><paramref name="delimiter"/> is a quote, CR, or LF.</exception>
|
||||||
|
public static string WriteToString(
|
||||||
|
IEnumerable<IEnumerable<string?>> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes <paramref name="rows"/> to <paramref name="writer"/>. The writer is not disposed or
|
||||||
|
/// flushed by this method.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="writer">The destination.</param>
|
||||||
|
/// <param name="rows">The rows to write; each inner sequence is one record's fields. A <c>null</c> field is written as empty.</param>
|
||||||
|
/// <param name="delimiter">The field separator (default comma).</param>
|
||||||
|
/// <param name="newline">The record terminator between rows (default CRLF). Not appended after the last row.</param>
|
||||||
|
/// <param name="quoteAllFields">When <c>true</c>, every field is quoted regardless of content.</param>
|
||||||
|
/// <exception cref="System.ArgumentNullException"><paramref name="writer"/> or <paramref name="rows"/> is <c>null</c>.</exception>
|
||||||
|
/// <exception cref="System.ArgumentException"><paramref name="delimiter"/> is a quote, CR, or LF.</exception>
|
||||||
|
public static void Write(
|
||||||
|
TextWriter writer,
|
||||||
|
IEnumerable<IEnumerable<string?>> 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<string?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Csv;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Csv;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The authoritative RFC 4180 read corpus for <see cref="CsvParser"/>. 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
|
||||||
|
/// <b>strict</b> malformed-input and <b>faithful</b> empty-line policies, lives here.
|
||||||
|
/// </summary>
|
||||||
|
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<FormatException>(() => CsvParser.Parse("\"unclosed"));
|
||||||
|
ex.Message.ShouldContain("Unterminated");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Quote_inside_unquoted_field_throws()
|
||||||
|
{
|
||||||
|
var ex = Should.Throw<FormatException>(() => CsvParser.Parse("ab\"c"));
|
||||||
|
ex.Message.ShouldContain("unquoted");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Character_after_closing_quote_throws()
|
||||||
|
{
|
||||||
|
var ex = Should.Throw<FormatException>(() => CsvParser.Parse("\"ab\"c"));
|
||||||
|
ex.Message.ShouldContain("after closing quote");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Malformed_message_carries_position()
|
||||||
|
{
|
||||||
|
var ex = Should.Throw<FormatException>(() => CsvParser.Parse("a,b\nab\"c"));
|
||||||
|
ex.Message.ShouldContain("line 2");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Delimiter_may_not_be_a_quote()
|
||||||
|
{
|
||||||
|
Should.Throw<ArgumentException>(() => 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<FormatException>(() => CsvParser.ParseWithHeader("name,name\nx,y"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Csv;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Csv;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Quote-on-demand and terminator behaviour for <see cref="CsvWriter"/>: 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.
|
||||||
|
/// </summary>
|
||||||
|
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<string[]>()).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<ArgumentException>(() =>
|
||||||
|
CsvWriter.WriteToString(new[] { new[] { "a" } }, delimiter: '"'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user