@@ -0,0 +1,266 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Import;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Unit coverage for <see cref="RsLogixSymbolImport"/>. Drives the parser through
|
||||
/// synthesised in-memory streams — the golden-snapshot fixture has its own dedicated
|
||||
/// test class (<see cref="RsLogixSymbolImportGoldenTests"/>).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RsLogixSymbolImportTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
private static RsLogixImportResult ParseString(string csv, ImportOptions? opts = null)
|
||||
{
|
||||
var importer = new RsLogixSymbolImport(NullLogger<RsLogixSymbolImport>.Instance);
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(csv));
|
||||
return importer.Parse(stream, Device, opts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_canonical_eight_file_letters_yields_eight_typed_tags()
|
||||
{
|
||||
// One row per file letter the v1 contract supports — N/F/B/L/ST/T/C/R. The expected
|
||||
// DataType is the file-letter resolution from RsLogixSymbolImport.TryResolveDataType,
|
||||
// not whatever the RSLogix-supplied DataType column says.
|
||||
const string csv = """
|
||||
Symbol,Address,Description,DataType,Scope
|
||||
S_N,N7:0,n,INT,Global
|
||||
S_F,F8:0,f,REAL,Global
|
||||
S_B,B3:0/0,b,BOOL,Global
|
||||
S_L,L9:0,l,LINT,Global
|
||||
S_ST,ST10:0,s,STRING,Global
|
||||
S_T,T4:0.ACC,t,TIMER,Global
|
||||
S_C,C5:0.ACC,c,COUNTER,Global
|
||||
S_R,R6:0.LEN,r,CONTROL,Global
|
||||
""";
|
||||
var result = ParseString(csv);
|
||||
result.ParsedCount.ShouldBe(8);
|
||||
result.SkippedCount.ShouldBe(0);
|
||||
result.ErrorCount.ShouldBe(0);
|
||||
result.Tags.Count.ShouldBe(8);
|
||||
|
||||
result.Tags[0].DataType.ShouldBe(AbLegacyDataType.Int);
|
||||
result.Tags[1].DataType.ShouldBe(AbLegacyDataType.Float);
|
||||
result.Tags[2].DataType.ShouldBe(AbLegacyDataType.Bit);
|
||||
result.Tags[3].DataType.ShouldBe(AbLegacyDataType.Long);
|
||||
result.Tags[4].DataType.ShouldBe(AbLegacyDataType.String);
|
||||
result.Tags[5].DataType.ShouldBe(AbLegacyDataType.TimerElement);
|
||||
result.Tags[6].DataType.ShouldBe(AbLegacyDataType.CounterElement);
|
||||
result.Tags[7].DataType.ShouldBe(AbLegacyDataType.ControlElement);
|
||||
|
||||
// Every tag should bind to the supplied device gateway and use the symbol verbatim
|
||||
// for its Name (no synthesised key — RSLogix symbols are already stable).
|
||||
result.Tags.ShouldAllBe(t => t.DeviceHostAddress == Device);
|
||||
result.Tags[0].Name.ShouldBe("S_N");
|
||||
result.Tags[2].Address.ShouldBe("B3:0/0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_skips_header_and_comment_lines()
|
||||
{
|
||||
// Comment lines (`;` / `#`) live both before and after the header — both forms must
|
||||
// survive the parser without bumping the tag count.
|
||||
const string csv = """
|
||||
; top-level comment
|
||||
# also a comment
|
||||
Symbol,Address,Description,DataType,Scope
|
||||
|
||||
; mid-stream comment
|
||||
Tag1,N7:0,desc,INT,Global
|
||||
# another comment
|
||||
|
||||
Tag2,F8:0,desc,REAL,Global
|
||||
""";
|
||||
var result = ParseString(csv);
|
||||
result.ParsedCount.ShouldBe(2);
|
||||
result.Tags[0].Name.ShouldBe("Tag1");
|
||||
result.Tags[1].Name.ShouldBe("Tag2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_malformed_row_skips_with_log_warning()
|
||||
{
|
||||
// Malformed rows: missing address. Default IgnoreInvalid=true skips them with a
|
||||
// warning logged — the surviving row still imports cleanly.
|
||||
var collector = new ListLogger<RsLogixSymbolImport>();
|
||||
const string csv = """
|
||||
Symbol,Address,Description,DataType,Scope
|
||||
Good,N7:0,ok,INT,Global
|
||||
Broken,,still here,INT,Global
|
||||
AlsoGood,F8:0,ok,REAL,Global
|
||||
""";
|
||||
var importer = new RsLogixSymbolImport(collector);
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(csv));
|
||||
var result = importer.Parse(stream, Device);
|
||||
|
||||
result.ParsedCount.ShouldBe(2);
|
||||
result.SkippedCount.ShouldBe(1);
|
||||
// The warning channel saw exactly one entry for the broken row.
|
||||
collector.Messages.ShouldContain(m => m.Contains("Broken") || m.Contains("missing"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_empty_stream_returns_empty_result()
|
||||
{
|
||||
var result = ParseString(string.Empty);
|
||||
result.ParsedCount.ShouldBe(0);
|
||||
result.SkippedCount.ShouldBe(0);
|
||||
result.ErrorCount.ShouldBe(0);
|
||||
result.Tags.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_handles_quoted_field_with_embedded_comma()
|
||||
{
|
||||
// Description with an embedded `,` must round-trip through the RFC-4180 splitter
|
||||
// without splitting the row into extra fields. Address column resolution still
|
||||
// lands on the correct file letter.
|
||||
const string csv = """
|
||||
Symbol,Address,Description,DataType,Scope
|
||||
Mixer,N7:5,"Mixer speed, RPM",INT,Global
|
||||
""";
|
||||
var result = ParseString(csv);
|
||||
result.ParsedCount.ShouldBe(1);
|
||||
result.Tags[0].Name.ShouldBe("Mixer");
|
||||
result.Tags[0].Address.ShouldBe("N7:5");
|
||||
result.Tags[0].DataType.ShouldBe(AbLegacyDataType.Int);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_doubled_quote_inside_quoted_field_decodes_to_single_quote()
|
||||
{
|
||||
// RFC 4180 doubled-quote escape — `""` inside a quoted field is a literal `"`.
|
||||
// Four-quote raw delimiter so the embedded triple-quote sequence in the CSV
|
||||
// payload doesn't terminate the literal early.
|
||||
const string csv = """"
|
||||
Symbol,Address,Description,DataType,Scope
|
||||
Quoted,N7:0,"He said ""hi""",INT,Global
|
||||
"""";
|
||||
var result = ParseString(csv);
|
||||
result.ParsedCount.ShouldBe(1);
|
||||
// The description goes to /dev/null today (AbLegacyTagDefinition has no Description
|
||||
// field) but the parser still has to consume the row without splitting on the inner
|
||||
// quotes — a parse-side regression would emit ParsedCount=0 / ErrorCount>=1.
|
||||
result.ErrorCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_scope_filter_drops_non_matching_rows()
|
||||
{
|
||||
const string csv = """
|
||||
Symbol,Address,Description,DataType,Scope
|
||||
G,N7:0,global,INT,Global
|
||||
L1,N7:1,local one,INT,Local:1
|
||||
L2,N7:2,local two,INT,Local:2
|
||||
""";
|
||||
var result = ParseString(csv, new ImportOptions(ScopeFilter: "Local:1"));
|
||||
result.ParsedCount.ShouldBe(1);
|
||||
result.Tags[0].Name.ShouldBe("L1");
|
||||
result.SkippedCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_handles_utf8_bom()
|
||||
{
|
||||
// RSLogix tools on Windows emit UTF-8 with BOM — make sure detectEncodingFromByte-
|
||||
// OrderMarks=true on the StreamReader strips the BOM rather than letting it become
|
||||
// part of the first column header (which would knock out the Symbol mapping).
|
||||
const string csv = "Symbol,Address,Description,DataType,Scope\nT,N7:0,desc,INT,Global\n";
|
||||
var bom = Encoding.UTF8.GetPreamble();
|
||||
var bytes = Encoding.UTF8.GetBytes(csv);
|
||||
var withBom = new byte[bom.Length + bytes.Length];
|
||||
bom.CopyTo(withBom, 0);
|
||||
bytes.CopyTo(withBom, bom.Length);
|
||||
|
||||
var importer = new RsLogixSymbolImport(NullLogger<RsLogixSymbolImport>.Instance);
|
||||
using var stream = new MemoryStream(withBom);
|
||||
var result = importer.Parse(stream, Device);
|
||||
|
||||
result.ParsedCount.ShouldBe(1);
|
||||
result.Tags[0].Name.ShouldBe("T");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_strict_mode_throws_on_first_invalid_address()
|
||||
{
|
||||
const string csv = """
|
||||
Symbol,Address,Description,DataType,Scope
|
||||
Good,N7:0,ok,INT,Global
|
||||
Broken,not-a-pccc-address,bad,INT,Global
|
||||
""";
|
||||
// IgnoreInvalid=false → the unrecognised PCCC address surfaces as InvalidDataException
|
||||
// rather than the silent-skip path.
|
||||
Should.Throw<InvalidDataException>(
|
||||
() => ParseString(csv, new ImportOptions(IgnoreInvalid: false)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_max_rows_caps_imports()
|
||||
{
|
||||
const string csv = """
|
||||
Symbol,Address,Description,DataType,Scope
|
||||
A,N7:0,a,INT,Global
|
||||
B,N7:1,b,INT,Global
|
||||
C,N7:2,c,INT,Global
|
||||
D,N7:3,d,INT,Global
|
||||
""";
|
||||
var result = ParseString(csv, new ImportOptions(MaxRowsToImport: 2));
|
||||
result.ParsedCount.ShouldBe(2);
|
||||
result.Tags.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_missing_required_column_throws_invalid_data()
|
||||
{
|
||||
// No Address column at all — structural failure, not per-row. Throws regardless of
|
||||
// the IgnoreInvalid knob (the latter governs per-row failures, not header shape).
|
||||
const string csv = """
|
||||
Symbol,Description,DataType,Scope
|
||||
T,desc,INT,Global
|
||||
""";
|
||||
Should.Throw<InvalidDataException>(() => ParseString(csv));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolveDataType_returns_false_for_garbage()
|
||||
{
|
||||
RsLogixSymbolImport.TryResolveDataType("not a pccc address", out _).ShouldBeFalse();
|
||||
RsLogixSymbolImport.TryResolveDataType("", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolveDataType_bit_index_overrides_file_letter()
|
||||
{
|
||||
// N7:0/3 — bit 3 of word 0 of integer file 7. The bit suffix forces Bit regardless
|
||||
// of N's normal Int classification.
|
||||
RsLogixSymbolImport.TryResolveDataType("N7:0/3", out var dt).ShouldBeTrue();
|
||||
dt.ShouldBe(AbLegacyDataType.Bit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal in-memory <see cref="ILogger{TCategoryName}"/> implementation so the unit
|
||||
/// tests can assert on the warning side-channel without depending on a logging
|
||||
/// framework. Captures the formatted message verbatim.
|
||||
/// </summary>
|
||||
private sealed class ListLogger<T> : ILogger<T>
|
||||
{
|
||||
public List<string> Messages { get; } = new();
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
Messages.Add(formatter(state, exception));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user