223 lines
7.5 KiB
C#
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);
|
|
}
|
|
}
|