Auto: s7-d1 — TIA Portal CSV + STEP 7 Classic AWL symbol import
Closes #299
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx.Infrastructure;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for the <c>import-symbols</c> CLI command. The command is intentionally
|
||||
/// thin (open file, hand to <c>TiaCsvImporter</c> / <c>AwlImporter</c>, serialise) —
|
||||
/// these tests focus on the I/O + flag-handling shape rather than re-running the
|
||||
/// parser.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ImportSymbolsCommandTests
|
||||
{
|
||||
private const string CanonicalTiaCsv = """
|
||||
Name,Path,Data type,Logical address,Comment,Hmi accessible
|
||||
MotorSpeed,Default,Int,%MW0,Motor speed,True
|
||||
TankLevel,Default,Real,%MD4,Tank level,True
|
||||
RunFlag,Default,Bool,%M0.0,Run flag,True
|
||||
""";
|
||||
|
||||
private const string CanonicalAwl = """
|
||||
VAR_GLOBAL
|
||||
Speed : INT;
|
||||
Pressure : REAL;
|
||||
END_VAR
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_with_tia_csv_emits_json_fragment_with_three_tags()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"s7-tia-{Guid.NewGuid():N}.csv");
|
||||
File.WriteAllText(path, CanonicalTiaCsv);
|
||||
try
|
||||
{
|
||||
using var console = new FakeInMemoryConsole();
|
||||
var cmd = new ImportSymbolsCommand
|
||||
{
|
||||
File = path,
|
||||
Format = "tia",
|
||||
Emit = "appsettings-fragment",
|
||||
};
|
||||
|
||||
await cmd.ExecuteAsync(console);
|
||||
|
||||
var output = console.ReadOutputString();
|
||||
output.ShouldContain("\"Tags\"");
|
||||
|
||||
using var doc = JsonDocument.Parse(output);
|
||||
var tags = doc.RootElement.GetProperty("Tags");
|
||||
tags.GetArrayLength().ShouldBe(3);
|
||||
tags[0].GetProperty("Name").GetString().ShouldBe("MotorSpeed");
|
||||
tags[0].GetProperty("Address").GetString().ShouldBe("MW0");
|
||||
tags[0].GetProperty("DataType").GetString().ShouldBe("Int16");
|
||||
tags[2].GetProperty("DataType").GetString().ShouldBe("Bool");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(path); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_with_summary_emit_prints_counters()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"s7-tia-{Guid.NewGuid():N}.csv");
|
||||
File.WriteAllText(path, CanonicalTiaCsv);
|
||||
try
|
||||
{
|
||||
using var console = new FakeInMemoryConsole();
|
||||
var cmd = new ImportSymbolsCommand
|
||||
{
|
||||
File = path,
|
||||
Format = "tia",
|
||||
Emit = "summary",
|
||||
};
|
||||
|
||||
await cmd.ExecuteAsync(console);
|
||||
|
||||
var output = console.ReadOutputString();
|
||||
output.ShouldContain("Imported 3");
|
||||
output.ShouldContain("udt-placeholders 0");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(path); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_with_awl_format_emits_var_global_tags()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"s7-awl-{Guid.NewGuid():N}.awl");
|
||||
File.WriteAllText(path, CanonicalAwl);
|
||||
try
|
||||
{
|
||||
using var console = new FakeInMemoryConsole();
|
||||
var cmd = new ImportSymbolsCommand
|
||||
{
|
||||
File = path,
|
||||
Format = "awl",
|
||||
Emit = "appsettings-fragment",
|
||||
};
|
||||
|
||||
await cmd.ExecuteAsync(console);
|
||||
|
||||
var output = console.ReadOutputString();
|
||||
using var doc = JsonDocument.Parse(output);
|
||||
var tags = doc.RootElement.GetProperty("Tags");
|
||||
tags.GetArrayLength().ShouldBe(2);
|
||||
tags[0].GetProperty("Address").GetString().ShouldBe("MW0");
|
||||
tags[1].GetProperty("Address").GetString().ShouldBe("MD2");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(path); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_with_output_path_writes_file_and_prints_summary()
|
||||
{
|
||||
var inputPath = Path.Combine(Path.GetTempPath(), $"s7-tia-{Guid.NewGuid():N}.csv");
|
||||
var outputPath = Path.Combine(Path.GetTempPath(), $"s7-tia-{Guid.NewGuid():N}.json");
|
||||
File.WriteAllText(inputPath, CanonicalTiaCsv);
|
||||
try
|
||||
{
|
||||
using var console = new FakeInMemoryConsole();
|
||||
var cmd = new ImportSymbolsCommand
|
||||
{
|
||||
File = inputPath,
|
||||
Format = "tia",
|
||||
Emit = "appsettings-fragment",
|
||||
Output = outputPath,
|
||||
};
|
||||
|
||||
await cmd.ExecuteAsync(console);
|
||||
|
||||
File.Exists(outputPath).ShouldBeTrue();
|
||||
var fileBody = File.ReadAllText(outputPath);
|
||||
using var doc = JsonDocument.Parse(fileBody);
|
||||
doc.RootElement.GetProperty("Tags").GetArrayLength().ShouldBe(3);
|
||||
|
||||
console.ReadOutputString().ShouldContain("Wrote 3");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(inputPath); } catch { /* best-effort */ }
|
||||
try { File.Delete(outputPath); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_with_missing_file_throws_command_exception()
|
||||
{
|
||||
var missing = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.csv");
|
||||
using var console = new FakeInMemoryConsole();
|
||||
var cmd = new ImportSymbolsCommand
|
||||
{
|
||||
File = missing,
|
||||
};
|
||||
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await cmd.ExecuteAsync(console));
|
||||
ex.ExitCode.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_with_unknown_format_throws_command_exception()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"s7-tia-{Guid.NewGuid():N}.csv");
|
||||
File.WriteAllText(path, CanonicalTiaCsv);
|
||||
try
|
||||
{
|
||||
using var console = new FakeInMemoryConsole();
|
||||
var cmd = new ImportSymbolsCommand
|
||||
{
|
||||
File = path,
|
||||
Format = "yaml",
|
||||
};
|
||||
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await cmd.ExecuteAsync(console));
|
||||
ex.ExitCode.ShouldBe(2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(path); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_with_unknown_emit_throws_command_exception()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"s7-tia-{Guid.NewGuid():N}.csv");
|
||||
File.WriteAllText(path, CanonicalTiaCsv);
|
||||
try
|
||||
{
|
||||
using var console = new FakeInMemoryConsole();
|
||||
var cmd = new ImportSymbolsCommand
|
||||
{
|
||||
File = path,
|
||||
Format = "tia",
|
||||
Emit = "xml",
|
||||
};
|
||||
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await cmd.ExecuteAsync(console));
|
||||
ex.ExitCode.ShouldBe(2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(path); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
(* Sample STEP 7 Classic AWL file — PR-S7-D1 / #299 fixture.
|
||||
Carries a VAR_GLOBAL block (M-area sequential offsets) and a
|
||||
DATA_BLOCK (DB1, sequential DBW/DBD offsets). Comments stripped
|
||||
before declaration parsing so this preamble does not affect counts.
|
||||
*)
|
||||
|
||||
VAR_GLOBAL
|
||||
// M-area globals — assigned sequentially: MW0, MW2, MD4
|
||||
Speed : INT; // motor speed setpoint
|
||||
Pressure : INT; // pressure transducer
|
||||
ActualValue : REAL;
|
||||
END_VAR
|
||||
|
||||
DATA_BLOCK DB1
|
||||
TITLE = 'Sample DB'
|
||||
VERSION : 0.1
|
||||
STRUCT
|
||||
CycleCount : INT; (* runtime cycle counter *)
|
||||
Setpoint : REAL := 50.0; (* setpoint with init *)
|
||||
ActualValue : REAL;
|
||||
RunFlag : BOOL;
|
||||
Recipe : STRING[20];
|
||||
END_STRUCT;
|
||||
BEGIN
|
||||
END_DATA_BLOCK
|
||||
@@ -0,0 +1,9 @@
|
||||
Name,Path,Data type,Logical address,Comment,Hmi accessible,Hmi visible,Hmi writeable,Length
|
||||
ProbeWord,Default tag table,UInt,%MW0,Probe word for liveness,True,True,True,
|
||||
SmokeI16,Default tag table,Int,%DB1.DBW10,Signed 16-bit smoke tag,True,True,True,
|
||||
SmokeI32,Default tag table,DInt,%DB1.DBD20,Signed 32-bit smoke tag,True,True,True,
|
||||
SmokeF32,Default tag table,Real,%DB1.DBD30,32-bit float smoke tag,True,True,True,
|
||||
SmokeBool,Default tag table,Bool,%DB1.DBX50.3,Boolean smoke tag,True,True,True,
|
||||
RecipeName,Default tag table,String,%DB2.DBB0,Recipe name string,True,True,True,32
|
||||
CookerCfg,Default tag table,"CookerSettings",%DB10.DBB0,UDT placeholder — wait for D2,True,True,True,
|
||||
HiddenInternal,Default tag table,Int,%MW100,Internal symbol — should be filtered,False,False,False,
|
||||
|
@@ -0,0 +1,6 @@
|
||||
Name;Path;Data type;Logical address;Comment;Hmi accessible;Hmi visible;Hmi writeable;Length
|
||||
ProbeWort;Standard-Variablentabelle;UInt;%MW0;Probe-Wort für Liveness;WAHR;WAHR;WAHR;
|
||||
SmokeI16;Standard-Variablentabelle;Int;%DB1.DBW10;Vorzeichenbehaftete 16-bit;WAHR;WAHR;WAHR;
|
||||
SmokeBool;Standard-Variablentabelle;Bool;%DB1.DBX50,3;Boolescher Smoke-Tag;WAHR;WAHR;WAHR;
|
||||
SmokeReal;Standard-Variablentabelle;Real;%DB1.DBD30;32-bit float;WAHR;WAHR;WAHR;
|
||||
VerstecktInternal;Standard-Variablentabelle;Int;%MW100;Internes Symbol — herausgefiltert;FALSCH;FALSCH;FALSCH;
|
||||
|
@@ -0,0 +1,83 @@
|
||||
using System.IO;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.SymbolImport;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D1 / #299 — golden-fixture integration test. Loads the canonical TIA Portal
|
||||
/// CSV export shipped under <c>Fixtures/sample_tia_export.csv</c>, materialises a
|
||||
/// driver-options object via <c>AddTiaCsvImport</c>, then exercises the runtime read
|
||||
/// path against the python-snap7 simulator.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The fixture's address layout (<c>%MW0</c>, <c>%DB1.DBW10</c>, <c>%DB1.DBD20</c>,
|
||||
/// <c>%DB1.DBD30</c>, <c>%DB1.DBX50.3</c>) is deliberately aligned with the seed
|
||||
/// offsets baked into the snap7 S7-1500 profile (<see cref="S7_1500Profile"/>) so
|
||||
/// a successful round-trip proves the importer's address normalisation lands at
|
||||
/// exactly the offsets the simulator seeds — a regression in <c>%</c>-stripping or
|
||||
/// decimal-comma rewriting surfaces here as a read-mismatch, not a flaky timeout.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The test is build-only by default (the assertions run only when the simulator
|
||||
/// fixture reports <c>SkipReason is null</c>) — local dev invokes it with snap7
|
||||
/// running; CI relies on the fixture's auto-skip when no simulator is reachable.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(Snap7ServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "S7_1500")]
|
||||
public sealed class TiaCsvImportIntegrationTests(Snap7ServerFixture sim)
|
||||
{
|
||||
private static string FixturePath(string name) =>
|
||||
Path.Combine(AppContext.BaseDirectory, "Fixtures", name);
|
||||
|
||||
[Fact]
|
||||
public async Task Driver_imports_csv_then_reads_seeded_tags()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
// Start with an empty options object pinned at the simulator's endpoint, then
|
||||
// layer the fixture CSV's tags on top via AddTiaCsvImport. The merge keeps the
|
||||
// endpoint config (Host, Port, CpuType) untouched and adds the imported tags.
|
||||
var baseOptions = new S7DriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
CpuType = global::S7.Net.CpuType.S71500,
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
Tags = [],
|
||||
};
|
||||
|
||||
var options = baseOptions.AddTiaCsvImport(FixturePath("sample_tia_export.csv"), out var importResult);
|
||||
|
||||
// Fixture has 8 rows: 6 importable + 1 UDT placeholder + 1 HMI-hidden (skipped).
|
||||
importResult.ParsedCount.ShouldBe(7);
|
||||
importResult.SkippedCount.ShouldBe(1);
|
||||
importResult.UdtPlaceholderCount.ShouldBe(1);
|
||||
options.Tags.Count.ShouldBe(7);
|
||||
|
||||
await using var drv = new S7Driver(options, driverInstanceId: "s7-tia-import");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
// Read the seed-aligned tags by their browse-name references. ProbeWord at MW0
|
||||
// doesn't have a snap7 seed (the simulator profile seeds DB1.DBW0 instead), so
|
||||
// we focus on the DB-anchored tags whose offsets match the S7_1500Profile seeds.
|
||||
var snapshots = await drv.ReadAsync(
|
||||
["SmokeI16", "SmokeI32", "SmokeF32", "SmokeBool"],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
snapshots.Count.ShouldBe(4);
|
||||
foreach (var s in snapshots) s.StatusCode.ShouldBe(0u, "imported-then-read must succeed end-to-end");
|
||||
|
||||
Convert.ToInt32(snapshots[0].Value).ShouldBe((int)S7_1500Profile.SmokeI16SeedValue);
|
||||
Convert.ToInt32(snapshots[1].Value).ShouldBe(S7_1500Profile.SmokeI32SeedValue);
|
||||
Convert.ToSingle(snapshots[2].Value).ShouldBe(S7_1500Profile.SmokeF32SeedValue, tolerance: 0.0001f);
|
||||
Convert.ToBoolean(snapshots[3].Value).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,11 @@
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Docker\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
<!-- PR-S7-D1 / #299 — TIA Portal CSV + STEP 7 Classic AWL fixtures used by the
|
||||
symbol-import integration test. -->
|
||||
<None Update="Fixtures\sample_tia_export.csv" CopyToOutputDirectory="PreserveNewest"/>
|
||||
<None Update="Fixtures\sample_tia_export_de_locale.csv" CopyToOutputDirectory="PreserveNewest"/>
|
||||
<None Update="Fixtures\sample_step7_classic.awl" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user