291 lines
11 KiB
C#
291 lines
11 KiB
C#
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));
|
|
}
|
|
}
|
|
}
|