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.S7.SymbolImport; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.SymbolImport; /// /// Unit coverage for . Drives the parser through /// synthesised in-memory streams; the golden-fixture path lives in the integration /// test project (Driver_imports_csv_then_reads_seeded_tags). /// [Trait("Category", "Unit")] public sealed class TiaCsvImporterTests { private static S7ImportResult ParseString(string csv, S7ImportOptions? opts = null) { var importer = new TiaCsvImporter(NullLogger.Instance); using var stream = new MemoryStream(Encoding.UTF8.GetBytes(csv)); return importer.Parse(stream, opts); } [Fact] public void Parse_canonical_TIA_csv_yields_correctly_typed_tag() { // Minimal canonical row: Name=Speed, Logical address=%MW0, Data type=Int. const string csv = """ Name,Path,Data type,Logical address,Comment,Hmi accessible,Hmi visible,Hmi writeable,Length Speed,Default tag table,Int,%MW0,Motor speed,True,True,True, """; var result = ParseString(csv); result.ParsedCount.ShouldBe(1); result.SkippedCount.ShouldBe(0); result.ErrorCount.ShouldBe(0); result.UdtPlaceholderCount.ShouldBe(0); result.Tags.Count.ShouldBe(1); var t = result.Tags[0]; t.Name.ShouldBe("Speed"); t.Address.ShouldBe("MW0"); t.DataType.ShouldBe(S7DataType.Int16); t.Writable.ShouldBeTrue(); } [Fact] public void Parse_DE_locale_csv_with_decimal_comma_imports_normalised_addresses() { // DE locale uses ';' as field separator and ',' as decimal separator. The Bool // address `%M0,5` would be `%M0.5` in en-US — auto-detect should rewrite it. const string csv = """ Name;Path;Data type;Logical address;Comment;Hmi accessible ProbeBit;Standard-Variablentabelle;Bool;%M0,5;Probe-Bit;WAHR """; var result = ParseString(csv); result.ParsedCount.ShouldBe(1); result.Tags[0].Address.ShouldBe("M0.5"); result.Tags[0].DataType.ShouldBe(S7DataType.Bool); } [Fact] public void Parse_skips_header_and_comment_lines() { // Comments (`#`) live both before and after the header — both forms must survive // the parser without bumping the tag count. `;` is NOT a comment marker (it's // the DE-locale separator). const string csv = """ # top-level comment Name,Path,Data type,Logical address,Comment,Hmi accessible # mid-stream comment Tag1,T,Int,%MW0,desc,True Tag2,T,Real,%MD4,desc,True """; var result = ParseString(csv); result.ParsedCount.ShouldBe(2); result.Tags[0].Name.ShouldBe("Tag1"); result.Tags[1].Name.ShouldBe("Tag2"); result.Tags[1].DataType.ShouldBe(S7DataType.Float32); } [Fact] public void Parse_udt_typed_row_imports_as_placeholder_and_increments_counter() { // TIA emits UDT references either as the bare quoted name ("MyUdt") or as the // literal "Struct" for inline anonymous structs. const string csv = """" Name,Path,Data type,Logical address,Comment,Hmi accessible CookerCfg,T,"CookerSettings",%DB10.DBB0,UDT instance,True InlineStruct,T,Struct,%DB11.DBB0,Inline struct,True """"; var result = ParseString(csv); result.ParsedCount.ShouldBe(2); result.UdtPlaceholderCount.ShouldBe(2); result.Tags.ShouldAllBe(t => t.DataType == S7DataType.Byte); result.Tags.ShouldAllBe(t => !t.Writable); } [Fact] public void Parse_hmi_accessible_false_skips_row() { const string csv = """ Name,Path,Data type,Logical address,Comment,Hmi accessible Visible,T,Int,%MW0,visible,True Hidden,T,Int,%MW2,hidden,False """; var result = ParseString(csv); result.ParsedCount.ShouldBe(1); result.SkippedCount.ShouldBe(1); result.Tags[0].Name.ShouldBe("Visible"); } [Fact] public void Parse_hmi_accessible_de_locale_falsch_skips_row() { // DE-locale boolean column values: WAHR=true, FALSCH=false. const string csv = """ Name;Path;Data type;Logical address;Comment;Hmi accessible Visible;T;Int;%MW10;visible;WAHR Hidden;T;Int;%MW12;hidden;FALSCH """; var result = ParseString(csv); result.ParsedCount.ShouldBe(1); result.SkippedCount.ShouldBe(1); result.Tags[0].Name.ShouldBe("Visible"); } [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.UdtPlaceholderCount.ShouldBe(0); result.Tags.ShouldBeEmpty(); } [Fact] public void Parse_missing_required_column_throws() { // No address column at all — structural failure. const string csv = """ Name,Path,Data type,Comment,Hmi accessible T,P,Int,desc,True """; Should.Throw(() => ParseString(csv)); } [Fact] public void Parse_db_word_address_imports_with_correct_type() { const string csv = """ Name,Path,Data type,Logical address,Comment,Hmi accessible Setpoint,T,Real,%DB1.DBD4,Setpoint,True """; var result = ParseString(csv); result.ParsedCount.ShouldBe(1); result.Tags[0].Address.ShouldBe("DB1.DBD4"); result.Tags[0].DataType.ShouldBe(S7DataType.Float32); } [Fact] public void Parse_string_with_length_column_propagates_string_length() { const string csv = """ Name,Path,Data type,Logical address,Comment,Hmi accessible,Hmi visible,Hmi writeable,Length Recipe,T,String,%DB2.DBB0,Recipe name,True,True,True,80 """; var result = ParseString(csv); result.ParsedCount.ShouldBe(1); result.Tags[0].DataType.ShouldBe(S7DataType.String); result.Tags[0].StringLength.ShouldBe(80); } [Fact] public void Parse_max_rows_caps_imports() { const string csv = """ Name,Path,Data type,Logical address,Comment,Hmi accessible A,T,Int,%MW0,a,True B,T,Int,%MW2,b,True C,T,Int,%MW4,c,True D,T,Int,%MW6,d,True """; var result = ParseString(csv, new S7ImportOptions(MaxRowsToImport: 2)); result.ParsedCount.ShouldBe(2); result.Tags.Count.ShouldBe(2); } [Fact] public void Parse_invalid_address_strict_throws() { const string csv = """ Name,Path,Data type,Logical address,Comment,Hmi accessible Bad,T,Int,%XYZ123,broken,True """; Should.Throw( () => ParseString(csv, new S7ImportOptions(IgnoreInvalid: false))); } [Fact] public void Parse_invalid_address_permissive_skips_with_log() { var collector = new ListLogger(); const string csv = """ Name,Path,Data type,Logical address,Comment,Hmi accessible Good,T,Int,%MW0,good,True Bad,T,Int,%XYZ123,broken,True """; var importer = new TiaCsvImporter(collector); using var stream = new MemoryStream(Encoding.UTF8.GetBytes(csv)); var result = importer.Parse(stream); result.ParsedCount.ShouldBe(1); result.ErrorCount.ShouldBe(1); collector.Messages.ShouldContain(m => m.Contains("Bad") || m.Contains("invalid")); } [Fact] public void Parse_handles_utf8_bom() { // TIA Portal on Windows can emit UTF-8 with a BOM — make sure // detectEncodingFromByteOrderMarks=true on the StreamReader strips it. const string csv = "Name,Path,Data type,Logical address,Comment,Hmi accessible\nT,P,Int,%MW0,d,True\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 TiaCsvImporter(NullLogger.Instance); using var stream = new MemoryStream(withBom); var result = importer.Parse(stream); result.ParsedCount.ShouldBe(1); result.Tags[0].Name.ShouldBe("T"); } [Fact] public void IsUdtTypeName_recognises_struct_and_quoted_names_but_not_primitives() { TiaCsvImporter.IsUdtTypeName("Struct").ShouldBeTrue(); TiaCsvImporter.IsUdtTypeName("\"MyUdt\"").ShouldBeTrue(); TiaCsvImporter.IsUdtTypeName("Int").ShouldBeFalse(); TiaCsvImporter.IsUdtTypeName("Real").ShouldBeFalse(); TiaCsvImporter.IsUdtTypeName("\"Int\"").ShouldBeFalse(); TiaCsvImporter.IsUdtTypeName("").ShouldBeFalse(); } [Fact] public void DetectSeparator_picks_semicolon_when_more_common() { // Header full of semicolons → DE locale. One value with a comma in a comment // shouldn't flip the detection. var lines = new[] { "Name;Path;Data type;Logical address" }; TiaCsvImporter.DetectSeparator(lines).ShouldBe(';'); } [Fact] public void DetectSeparator_picks_comma_for_us_locale() { var lines = new[] { "Name,Path,Data type,Logical address" }; TiaCsvImporter.DetectSeparator(lines).ShouldBe(','); } [Fact] public void NormaliseAddress_strips_percent_and_rewrites_de_comma() { TiaCsvImporter.NormaliseAddress("%MW0", deLocale: false).ShouldBe("MW0"); TiaCsvImporter.NormaliseAddress("%M0,5", deLocale: true).ShouldBe("M0.5"); TiaCsvImporter.NormaliseAddress("M0.5", deLocale: false).ShouldBe("M0.5"); } /// In-memory for assertion on the warning channel. 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)); } } }