Auto: s7-d1 — TIA Portal CSV + STEP 7 Classic AWL symbol import

Closes #299
This commit is contained in:
Joseph Doherty
2026-04-26 06:32:18 -04:00
parent ac3fd45cc6
commit a908dff7b5
20 changed files with 2526 additions and 0 deletions

View File

@@ -0,0 +1,222 @@
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);
}
}

View File

@@ -0,0 +1,95 @@
using System.IO;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.SymbolImport;
/// <summary>
/// Coverage for <c>S7DriverFactoryExtensions.AddTiaCsvImport</c> and
/// <c>AddAwlImport</c>. The extension methods are thin wrappers around the importers
/// so the surface area to test is the merge behaviour + result propagation.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7DriverFactoryAddImportTests
{
[Fact]
public void AddTiaCsvImport_concatenates_imported_tags_onto_existing_options()
{
var path = Path.Combine(Path.GetTempPath(), $"tia-{Guid.NewGuid():N}.csv");
File.WriteAllText(path, """
Name,Path,Data type,Logical address,Comment,Hmi accessible
Imported1,T,Int,%MW0,desc,True
Imported2,T,Real,%MD4,desc,True
""");
try
{
var existing = new S7TagDefinition("PreExisting", "MW10", S7DataType.Int16);
var options = new S7DriverOptions
{
Host = "192.168.1.10",
Tags = [existing],
};
var updated = options.AddTiaCsvImport(path, out var result);
result.ParsedCount.ShouldBe(2);
updated.Tags.Count.ShouldBe(3);
updated.Tags[0].Name.ShouldBe("PreExisting");
updated.Tags[1].Name.ShouldBe("Imported1");
updated.Tags[2].Name.ShouldBe("Imported2");
// Other options fields propagate untouched.
updated.Host.ShouldBe("192.168.1.10");
}
finally
{
try { File.Delete(path); } catch { /* best-effort */ }
}
}
[Fact]
public void AddTiaCsvImportWithResult_returns_options_and_result_tuple()
{
var path = Path.Combine(Path.GetTempPath(), $"tia-{Guid.NewGuid():N}.csv");
File.WriteAllText(path, """
Name,Path,Data type,Logical address,Comment,Hmi accessible
T,P,Int,%MW0,d,True
""");
try
{
var (updated, result) = new S7DriverOptions { Host = "h" }.AddTiaCsvImportWithResult(path);
updated.Tags.Count.ShouldBe(1);
result.ParsedCount.ShouldBe(1);
}
finally
{
try { File.Delete(path); } catch { /* best-effort */ }
}
}
[Fact]
public void AddAwlImport_appends_var_global_declarations()
{
var path = Path.Combine(Path.GetTempPath(), $"awl-{Guid.NewGuid():N}.awl");
File.WriteAllText(path, """
VAR_GLOBAL
Speed : INT;
Pressure : REAL;
END_VAR
""");
try
{
var options = new S7DriverOptions { Host = "h", Tags = [] };
var updated = options.AddAwlImport(path, out var result);
result.ParsedCount.ShouldBe(2);
updated.Tags.Count.ShouldBe(2);
updated.Tags[0].Address.ShouldBe("MW0");
updated.Tags[1].Address.ShouldBe("MD2");
}
finally
{
try { File.Delete(path); } catch { /* best-effort */ }
}
}
}

View File

@@ -0,0 +1,290 @@
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;
/// <summary>
/// Unit coverage for <see cref="TiaCsvImporter"/>. Drives the parser through
/// synthesised in-memory streams; the golden-fixture path lives in the integration
/// test project (<c>Driver_imports_csv_then_reads_seeded_tags</c>).
/// </summary>
[Trait("Category", "Unit")]
public sealed class TiaCsvImporterTests
{
private static S7ImportResult ParseString(string csv, S7ImportOptions? opts = null)
{
var importer = new TiaCsvImporter(NullLogger<TiaCsvImporter>.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<InvalidDataException>(() => 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<InvalidDataException>(
() => ParseString(csv, new S7ImportOptions(IgnoreInvalid: false)));
}
[Fact]
public void Parse_invalid_address_permissive_skips_with_log()
{
var collector = new ListLogger<TiaCsvImporter>();
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<TiaCsvImporter>.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");
}
/// <summary>In-memory <see cref="ILogger{T}"/> for assertion on the warning channel.</summary>
private sealed class ListLogger<T> : ILogger<T>
{
public List<string> Messages { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
Messages.Add(formatter(state, exception));
}
}
}