Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/Import/RsLogixSymbolImportTests.cs
2026-04-26 04:13:13 -04:00

267 lines
10 KiB
C#

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