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 . The AWL grammar is best-effort /// position-based; these tests pin the assignment rules so a regression in offset /// accounting surfaces immediately. /// [Trait("Category", "Unit")] public sealed class AwlImporterTests { private static S7ImportResult ParseString(string awl, S7ImportOptions? opts = null) { var importer = new AwlImporter(NullLogger.Instance); using var stream = new MemoryStream(Encoding.UTF8.GetBytes(awl)); return importer.Parse(stream, opts); } [Fact] public void Parse_var_global_three_ints_yields_sequential_MW_addresses() { const string awl = """ VAR_GLOBAL Speed : INT; Pressure : INT; Level : INT; END_VAR """; var result = ParseString(awl); result.ParsedCount.ShouldBe(3); result.Tags.Count.ShouldBe(3); result.Tags[0].Name.ShouldBe("Speed"); result.Tags[0].Address.ShouldBe("MW0"); result.Tags[0].DataType.ShouldBe(S7DataType.Int16); result.Tags[1].Name.ShouldBe("Pressure"); result.Tags[1].Address.ShouldBe("MW2"); result.Tags[2].Name.ShouldBe("Level"); result.Tags[2].Address.ShouldBe("MW4"); } [Fact] public void Parse_data_block_mixed_int_real_yields_db_prefixed_addresses() { const string awl = """ DATA_BLOCK DB1 STRUCT CycleCount : INT; Setpoint : REAL; ActualValue : REAL; END_STRUCT; BEGIN END_DATA_BLOCK """; var result = ParseString(awl); result.ParsedCount.ShouldBe(3); result.Tags[0].Name.ShouldBe("CycleCount"); result.Tags[0].Address.ShouldBe("DB1.DBW0"); result.Tags[0].DataType.ShouldBe(S7DataType.Int16); result.Tags[1].Name.ShouldBe("Setpoint"); result.Tags[1].Address.ShouldBe("DB1.DBD2"); result.Tags[1].DataType.ShouldBe(S7DataType.Float32); result.Tags[2].Name.ShouldBe("ActualValue"); result.Tags[2].Address.ShouldBe("DB1.DBD6"); result.Tags[2].DataType.ShouldBe(S7DataType.Float32); } [Fact] public void Parse_strips_block_and_line_comments() { const string awl = """ (* Block comment — ignored *) VAR_GLOBAL // Line comment — ignored Counter : INT; // inline comment after the decl (* another (* faux-nested *) block — non-nesting matcher should still consume it *) Velocity : REAL; END_VAR """; var result = ParseString(awl); // Block-comment regex is non-greedy; "another " up to first "*)" is one comment, the rest // becomes part of the body but the regex extracts the two valid declarations cleanly. result.ParsedCount.ShouldBeGreaterThanOrEqualTo(2); result.Tags.ShouldContain(t => t.Name == "Counter" && t.DataType == S7DataType.Int16); result.Tags.ShouldContain(t => t.Name == "Velocity" && t.DataType == S7DataType.Float32); } [Fact] public void Parse_empty_body_returns_empty_result() { var result = ParseString(string.Empty); result.ParsedCount.ShouldBe(0); result.Tags.ShouldBeEmpty(); } [Fact] public void Parse_var_global_with_initial_values_strips_init_clause() { const string awl = """ VAR_GLOBAL SetVal : INT := 42; Threshold : REAL := 3.14; END_VAR """; var result = ParseString(awl); result.ParsedCount.ShouldBe(2); result.Tags[0].DataType.ShouldBe(S7DataType.Int16); result.Tags[1].DataType.ShouldBe(S7DataType.Float32); } [Fact] public void Parse_udt_typed_decl_imports_as_placeholder() { const string awl = """ DATA_BLOCK DB5 STRUCT Cfg : "MyConfig"; Flag : BOOL; END_STRUCT; BEGIN END_DATA_BLOCK """; var result = ParseString(awl); result.ParsedCount.ShouldBe(2); result.UdtPlaceholderCount.ShouldBe(1); var placeholder = result.Tags.First(t => t.Name == "Cfg"); placeholder.DataType.ShouldBe(S7DataType.Byte); placeholder.Writable.ShouldBeFalse(); } [Fact] public void Parse_string_with_length_uses_n_plus_2_byte_layout() { const string awl = """ DATA_BLOCK DB7 STRUCT FirstByte : BYTE; Recipe : STRING[80]; Trailing : INT; END_STRUCT; BEGIN END_DATA_BLOCK """; var result = ParseString(awl); result.ParsedCount.ShouldBe(3); // Recipe (STRING[80]) consumes 82 bytes starting at offset 1 (rounded to 2 = 2); // trailing INT lands at 84. var first = result.Tags.First(t => t.Name == "FirstByte"); first.Address.ShouldBe("DB7.DBB0"); var trailing = result.Tags.First(t => t.Name == "Trailing"); trailing.Address.ShouldBe("DB7.DBW84"); } [Fact] public void StripComments_removes_block_and_line_comments() { var input = "VAR_GLOBAL (* hi *)\nA : INT; // trailing\nEND_VAR"; var stripped = AwlImporter.StripComments(input); stripped.ShouldNotContain("(*"); stripped.ShouldNotContain("*)"); stripped.ShouldNotContain("//"); stripped.ShouldContain("A : INT;"); } [Fact] public void ResolveType_maps_primitive_step7_types_correctly() { AwlImporter.ResolveType("INT").S7Type.ShouldBe(S7DataType.Int16); AwlImporter.ResolveType("REAL").S7Type.ShouldBe(S7DataType.Float32); AwlImporter.ResolveType("LREAL").S7Type.ShouldBe(S7DataType.Float64); AwlImporter.ResolveType("BOOL").S7Type.ShouldBe(S7DataType.Bool); AwlImporter.ResolveType("DINT").S7Type.ShouldBe(S7DataType.Int32); AwlImporter.ResolveType("STRING[20]").S7Type.ShouldBe(S7DataType.String); AwlImporter.ResolveType("STRING[20]").SizeBytes.ShouldBe(22); AwlImporter.ResolveType("STRUCT").IsUdt.ShouldBeTrue(); AwlImporter.ResolveType("\"MyType\"").IsUdt.ShouldBeTrue(); } [Fact] public void AlignTo_rounds_word_offsets_up_to_word_boundary() { AwlImporter.AlignTo(0, 2).ShouldBe(0); AwlImporter.AlignTo(1, 2).ShouldBe(2); AwlImporter.AlignTo(3, 2).ShouldBe(4); AwlImporter.AlignTo(2, 4).ShouldBe(2); // 2-byte alignment is the cap AwlImporter.AlignTo(0, 1).ShouldBe(0); // bytes don't need alignment } [Fact] public void ExtractDbNumber_pulls_db_number_from_header() { AwlImporter.ExtractDbNumber(" DB1").ShouldBe(1); AwlImporter.ExtractDbNumber(" DB42 \"Name\"").ShouldBe(42); AwlImporter.ExtractDbNumber("\"NameOnly\"").ShouldBeNull(); } [Fact] public void Parse_max_rows_caps_imports() { const string awl = """ VAR_GLOBAL A : INT; B : INT; C : INT; D : INT; END_VAR """; var result = ParseString(awl, new S7ImportOptions(MaxRowsToImport: 2)); result.ParsedCount.ShouldBe(2); } }