Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/SymbolImport/AwlImporterTests.cs
2026-04-26 06:32:18 -04:00

223 lines
7.5 KiB
C#

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;
/// <summary>
/// Unit coverage for <see cref="AwlImporter"/>. The AWL grammar is best-effort
/// position-based; these tests pin the assignment rules so a regression in offset
/// accounting surfaces immediately.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AwlImporterTests
{
private static S7ImportResult ParseString(string awl, S7ImportOptions? opts = null)
{
var importer = new AwlImporter(NullLogger<AwlImporter>.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);
}
}