using System.IO; using System.Text; 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 and 's /// instance-DB recognition path (PR-S7-D3 / #301). The resolver-level cases drive the /// class directly with synthesised inputs; the importer-level /// cases drive the parser through in-memory CSV streams that mix Global + Instance rows /// to confirm both buckets land correctly and that /// tracks just the instance-DB rows. /// [Trait("Category", "Unit")] public sealed class InstanceDbResolverTests { 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 IsInstanceDbType_recognises_canonical_and_locale_variants() { // en-US canonical shapes — TIA's "Show all tags" CSV emits any of these depending on // export options and TIA Portal version. InstanceDbResolver.IsInstanceDbType("Instance DB").ShouldBeTrue(); InstanceDbResolver.IsInstanceDbType("instance db").ShouldBeTrue(); InstanceDbResolver.IsInstanceDbType("Instance").ShouldBeTrue(); InstanceDbResolver.IsInstanceDbType("Instance Data Block").ShouldBeTrue(); InstanceDbResolver.IsInstanceDbType("instance-db").ShouldBeTrue(); InstanceDbResolver.IsInstanceDbType("\"Instance DB\"").ShouldBeTrue(); // quotes survive // German export variants. InstanceDbResolver.IsInstanceDbType("Instanz-DB").ShouldBeTrue(); InstanceDbResolver.IsInstanceDbType("Instanz-Datenbaustein").ShouldBeTrue(); // Negative cases — Global rows must NOT be confused for instance DBs. InstanceDbResolver.IsInstanceDbType("Global DB").ShouldBeFalse(); InstanceDbResolver.IsInstanceDbType("Global").ShouldBeFalse(); InstanceDbResolver.IsInstanceDbType("").ShouldBeFalse(); InstanceDbResolver.IsInstanceDbType(null).ShouldBeFalse(); } [Fact] public void IsGlobalDbType_recognises_canonical_and_locale_variants() { InstanceDbResolver.IsGlobalDbType("Global DB").ShouldBeTrue(); InstanceDbResolver.IsGlobalDbType("global").ShouldBeTrue(); InstanceDbResolver.IsGlobalDbType("Global Data Block").ShouldBeTrue(); InstanceDbResolver.IsGlobalDbType("Globaler Datenbaustein").ShouldBeTrue(); InstanceDbResolver.IsGlobalDbType("Instance DB").ShouldBeFalse(); InstanceDbResolver.IsGlobalDbType("").ShouldBeFalse(); } [Fact] public void TryResolve_with_resolved_address_returns_tag_at_that_address() { var resolver = new InstanceDbResolver(NullLogger.Instance); var row = new InstanceDbRow( Name: "MotorFB_1.Speed", DbType: "Instance DB", LogicalAddress: "%DB7.DBW0", DataType: "Int"); resolver.TryResolve(row, out var tag).ShouldBeTrue(); tag.ShouldNotBeNull(); tag!.Name.ShouldBe("MotorFB_1.Speed"); tag.Address.ShouldBe("DB7.DBW0"); tag.DataType.ShouldBe(S7DataType.Int16); } [Fact] public void TryResolve_with_empty_address_but_interface_offsets_computes_address() { // FB interface declares Speed at member offset 4 within an FB instance whose base // sits at offset 8 inside DB12. Resolver computes DB12.DBW(8 + 4) = DB12.DBW12. var resolver = new InstanceDbResolver(NullLogger.Instance); var row = new InstanceDbRow( Name: "PumpFB_2.Speed", DbType: "Instance DB", LogicalAddress: null, DataType: "Int", ParentDbNumber: 12, ParentBaseOffset: 8, MemberOffset: 4); resolver.TryResolve(row, out var tag).ShouldBeTrue(); tag.ShouldNotBeNull(); tag!.Address.ShouldBe("DB12.DBW12"); tag.DataType.ShouldBe(S7DataType.Int16); } [Fact] public void TryResolve_with_empty_address_for_bool_uses_bit_offset() { // BOOL members carry a bit offset; resolver builds DBn.DBX{byte}.{bit}. var resolver = new InstanceDbResolver(NullLogger.Instance); var row = new InstanceDbRow( Name: "PumpFB_2.Enabled", DbType: "Instance DB", LogicalAddress: null, DataType: "Bool", ParentDbNumber: 12, ParentBaseOffset: 0, MemberOffset: 6, MemberBitOffset: 3); resolver.TryResolve(row, out var tag).ShouldBeTrue(); tag.ShouldNotBeNull(); tag!.Address.ShouldBe("DB12.DBX6.3"); tag.DataType.ShouldBe(S7DataType.Bool); } [Fact] public void TryResolve_with_no_address_and_no_interface_info_fails() { var resolver = new InstanceDbResolver(NullLogger.Instance); var row = new InstanceDbRow( Name: "Orphan.Member", DbType: "Instance DB", LogicalAddress: null, DataType: "Int"); resolver.TryResolve(row, out var tag).ShouldBeFalse(); tag.ShouldBeNull(); } [Fact] public void TryResolve_with_unparseable_address_fails() { var resolver = new InstanceDbResolver(NullLogger.Instance); var row = new InstanceDbRow( Name: "Garbage", DbType: "Instance DB", LogicalAddress: "%XYZ-not-an-address", DataType: "Int"); resolver.TryResolve(row, out var tag).ShouldBeFalse(); tag.ShouldBeNull(); } [Fact] public void Parse_five_instance_db_rows_with_resolved_addresses_yield_five_tags() { // Five instance-DB members under the same MotorFB_Instance, fully resolved by TIA. const string csv = """ Name,Path,Data type,Logical address,Comment,Hmi accessible,DB type MotorFB_1.Speed,FB,Int,%DB7.DBW0,Speed setpoint,True,Instance DB MotorFB_1.Accel,FB,Int,%DB7.DBW2,Acceleration ramp,True,Instance DB MotorFB_1.Setpoint,FB,Real,%DB7.DBD4,Position setpoint,True,Instance DB MotorFB_1.Enabled,FB,Bool,%DB7.DBX8.0,Drive enabled bit,True,Instance DB MotorFB_1.Fault,FB,Bool,%DB7.DBX8.1,Fault bit,True,Instance DB """; var result = ParseString(csv); result.ParsedCount.ShouldBe(5); result.InstanceDbCount.ShouldBe(5); result.UdtPlaceholderCount.ShouldBe(0); result.SkippedCount.ShouldBe(0); result.ErrorCount.ShouldBe(0); result.Tags[0].Address.ShouldBe("DB7.DBW0"); result.Tags[0].DataType.ShouldBe(S7DataType.Int16); result.Tags[1].Address.ShouldBe("DB7.DBW2"); result.Tags[2].Address.ShouldBe("DB7.DBD4"); result.Tags[2].DataType.ShouldBe(S7DataType.Float32); result.Tags[3].Address.ShouldBe("DB7.DBX8.0"); result.Tags[3].DataType.ShouldBe(S7DataType.Bool); result.Tags[4].Address.ShouldBe("DB7.DBX8.1"); } [Fact] public void Parse_mixed_global_and_instance_rows_handles_both() { // Two global-DB rows (no DB type column → treated as Global by D1) + three // instance-DB rows (DB type=Instance DB → routed through the resolver). const string csv = """ Name,Path,Data type,Logical address,Comment,Hmi accessible,DB type ProbeWord,Tags,UInt,%MW0,Probe,True, Setpoint,Tags,Real,%DB1.DBD4,Setpoint,True,Global DB FB1.Speed,FB,Int,%DB7.DBW0,FB speed,True,Instance DB FB1.Accel,FB,Int,%DB7.DBW2,FB accel,True,Instance DB FB1.Enabled,FB,Bool,%DB7.DBX8.0,FB enabled,True,Instance DB """; var result = ParseString(csv); result.ParsedCount.ShouldBe(5); result.InstanceDbCount.ShouldBe(3); result.SkippedCount.ShouldBe(0); result.ErrorCount.ShouldBe(0); result.UdtPlaceholderCount.ShouldBe(0); // First two rows are global; last three are instance. result.Tags[0].Name.ShouldBe("ProbeWord"); result.Tags[1].Name.ShouldBe("Setpoint"); result.Tags[2].Name.ShouldBe("FB1.Speed"); result.Tags[3].Name.ShouldBe("FB1.Accel"); result.Tags[4].Name.ShouldBe("FB1.Enabled"); } [Fact] public void Parse_instance_db_with_locale_variants_are_all_recognised() { // Verify every locale variant of the DB type column gets routed through the // resolver. One row per variant; all five must land in InstanceDbCount. const string csv = """ Name,Path,Data type,Logical address,Comment,Hmi accessible,DB type T1,FB,Int,%DB10.DBW0,t1,True,Instance T2,FB,Int,%DB10.DBW2,t2,True,Instance DB T3,FB,Int,%DB10.DBW4,t3,True,Instance-DB T4,FB,Int,%DB10.DBW6,t4,True,Instance Data Block T5,FB,Int,%DB10.DBW8,t5,True,Instanz-DB """; var result = ParseString(csv); result.ParsedCount.ShouldBe(5); result.InstanceDbCount.ShouldBe(5); } [Fact] public void Parse_data_type_resolution_unchanged_from_d1_for_instance_rows() { // Sanity: instance-DB rows go through the same TryResolveDataType path as global // rows, so the existing primitive-type table is honoured. Spot-check the wider // set: Bool, Int, DInt, Real, String. const string csv = """ Name,Path,Data type,Logical address,Comment,Hmi accessible,DB type,Length FB.Bit,FB,Bool,%DB1.DBX0.0,b,True,Instance DB, FB.I16,FB,Int,%DB1.DBW2,i16,True,Instance DB, FB.I32,FB,DInt,%DB1.DBD4,i32,True,Instance DB, FB.F32,FB,Real,%DB1.DBD8,f32,True,Instance DB, FB.Str,FB,String,%DB1.DBB12,s,True,Instance DB,32 """; var result = ParseString(csv); result.ParsedCount.ShouldBe(5); result.InstanceDbCount.ShouldBe(5); result.Tags[0].DataType.ShouldBe(S7DataType.Bool); result.Tags[1].DataType.ShouldBe(S7DataType.Int16); result.Tags[2].DataType.ShouldBe(S7DataType.Int32); result.Tags[3].DataType.ShouldBe(S7DataType.Float32); result.Tags[4].DataType.ShouldBe(S7DataType.String); result.Tags[4].StringLength.ShouldBe(32); } [Fact] public void Parse_global_rows_still_count_zero_instance_db() { // Negative regression — a CSV without any Instance-DB rows must report // InstanceDbCount = 0 even when the DB type column is present. const string csv = """ Name,Path,Data type,Logical address,Comment,Hmi accessible,DB type A,T,Int,%MW0,a,True,Global DB B,T,Real,%DB1.DBD4,b,True,Global DB """; var result = ParseString(csv); result.ParsedCount.ShouldBe(2); result.InstanceDbCount.ShouldBe(0); } }