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; /// /// Unit coverage for . Drives the parser through /// synthesised in-memory streams — the golden-snapshot fixture has its own dedicated /// test class (). /// [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.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(); 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.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( () => 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(() => 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); } /// /// Minimal in-memory implementation so the unit /// tests can assert on the warning side-channel without depending on a logging /// framework. Captures the formatted message verbatim. /// private sealed class ListLogger : ILogger { public List Messages { get; } = new(); public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { Messages.Add(formatter(state, exception)); } } }