@@ -0,0 +1,10 @@
|
||||
Name,Path,Data type,Logical address,Comment,Hmi accessible,Hmi visible,Hmi writeable,Length,DB type
|
||||
ProbeWord,Default tag table,UInt,%MW0,Probe word for liveness,True,True,True,,
|
||||
GlobalSetpoint,Default tag table,Int,%DB1.DBW10,Standalone global-DB tag,True,True,True,,Global DB
|
||||
GlobalCounter,Default tag table,DInt,%DB1.DBD20,Standalone global-DB tag,True,True,True,,Global DB
|
||||
MotorFB_Inst.Speed,FB instances,Int,%DB1.DBW10,FB-instance member resolved into DB1 layout,True,True,True,,Instance DB
|
||||
MotorFB_Inst.Setpoint,FB instances,Real,%DB1.DBD30,FB-instance member resolved into DB1 layout,True,True,True,,Instance DB
|
||||
MotorFB_Inst.Enabled,FB instances,Bool,%DB1.DBX50.3,FB-instance member resolved into DB1 layout,True,True,True,,Instance DB
|
||||
PumpFB_Inst.Counter,FB instances,DInt,%DB1.DBD20,Second FB-instance member,True,True,True,,Instance DB
|
||||
PumpFB_Inst.WriteScratch,FB instances,UInt,%DB1.DBW100,Second FB-instance member — writable scratch,True,True,True,,Instance DB
|
||||
HiddenInternal,Default tag table,Int,%MW100,Internal symbol — should be filtered,False,False,False,,
|
||||
|
@@ -0,0 +1,96 @@
|
||||
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-D3 / #301 — golden-fixture integration test for instance-DB / FB parameter
|
||||
/// resolution. Loads <c>Fixtures/sample_tia_export_with_fb_instance.csv</c>, materialises
|
||||
/// a driver-options object via <see cref="S7DriverFactoryExtensions.AddTiaCsvImport"/>,
|
||||
/// and exercises the runtime read path against the python-snap7 simulator using the
|
||||
/// resolved instance-DB addresses.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The fixture mixes three categories so a single import covers the full
|
||||
/// <c>DB type</c> column matrix:
|
||||
/// <list type="bullet">
|
||||
/// <item>Two global-DB tags (<c>DB type = Global DB</c>) — pre-existing D1 path.</item>
|
||||
/// <item>Five instance-DB tags (<c>DB type = Instance DB</c>) — new D3 path.</item>
|
||||
/// <item>One HMI-hidden row — must be filtered.</item>
|
||||
/// <item>One M-area probe (no <c>DB type</c>) — global by default.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The instance-DB tag addresses deliberately overlap with the snap7 seed offsets
|
||||
/// baked into <see cref="S7_1500Profile"/> so a successful round-trip proves the
|
||||
/// resolver normalises addresses identically to the global-DB path. The simulator
|
||||
/// doesn't know (or care) that a tag came from an FB-instance row — what we're
|
||||
/// testing is that the <em>importer</em> recognised it and the <em>driver</em>
|
||||
/// accepted the resolved address verbatim.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Auto-skips when no simulator is reachable (build-only on hosts without snap7).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(Snap7ServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "S7_1500")]
|
||||
public sealed class InstanceDbImportIntegrationTests(Snap7ServerFixture sim)
|
||||
{
|
||||
private static string FixturePath(string name) =>
|
||||
Path.Combine(AppContext.BaseDirectory, "Fixtures", name);
|
||||
|
||||
[Fact]
|
||||
public async Task Driver_resolves_fb_instance_then_reads_seeded_member()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
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_with_fb_instance.csv"),
|
||||
out var importResult);
|
||||
|
||||
// Fixture: 9 rows total = 1 probe + 2 global-DB + 5 instance-DB + 1 hidden.
|
||||
// Hidden filtered → SkippedCount = 1; everything else lands.
|
||||
importResult.ParsedCount.ShouldBe(8);
|
||||
importResult.SkippedCount.ShouldBe(1);
|
||||
importResult.InstanceDbCount.ShouldBe(5);
|
||||
importResult.UdtPlaceholderCount.ShouldBe(0);
|
||||
importResult.ErrorCount.ShouldBe(0);
|
||||
options.Tags.Count.ShouldBe(8);
|
||||
|
||||
await using var drv = new S7Driver(options, driverInstanceId: "s7-tia-import-fb");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
// Read three resolved instance-DB members. Each address overlaps a snap7 seed in
|
||||
// S7_1500Profile, so a successful round-trip proves the resolver pointed the
|
||||
// driver at the right absolute byte offset.
|
||||
var snapshots = await drv.ReadAsync(
|
||||
["MotorFB_Inst.Speed", "MotorFB_Inst.Setpoint", "MotorFB_Inst.Enabled"],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
snapshots.Count.ShouldBe(3);
|
||||
foreach (var s in snapshots)
|
||||
s.StatusCode.ShouldBe(0u, "instance-DB resolved address must read end-to-end");
|
||||
|
||||
// Speed sits at DB1.DBW10 → SmokeI16Seed; Setpoint at DB1.DBD30 → SmokeF32Seed;
|
||||
// Enabled at DB1.DBX50.3 → SmokeBool.
|
||||
Convert.ToInt32(snapshots[0].Value).ShouldBe((int)S7_1500Profile.SmokeI16SeedValue);
|
||||
Convert.ToSingle(snapshots[1].Value).ShouldBe(S7_1500Profile.SmokeF32SeedValue, tolerance: 0.0001f);
|
||||
Convert.ToBoolean(snapshots[2].Value).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@
|
||||
<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"/>
|
||||
<!-- PR-S7-D3 / #301 — instance-DB / FB parameter resolution fixture. -->
|
||||
<None Update="Fixtures\sample_tia_export_with_fb_instance.csv" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
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="InstanceDbResolver"/> and <see cref="TiaCsvImporter"/>'s
|
||||
/// instance-DB recognition path (PR-S7-D3 / #301). The resolver-level cases drive the
|
||||
/// class directly with synthesised <see cref="InstanceDbRow"/> inputs; the importer-level
|
||||
/// cases drive the parser through in-memory CSV streams that mix Global + Instance rows
|
||||
/// to confirm both buckets land correctly and that <see cref="S7ImportResult.InstanceDbCount"/>
|
||||
/// tracks just the instance-DB rows.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class InstanceDbResolverTests
|
||||
{
|
||||
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 IsInstanceDbType_recognises_canonical_and_locale_variants()
|
||||
{
|
||||
// en-US canonical shapes — TIA's "Show all tags" CSV emits any of these depending on
|
||||
// export options and TIA Portal version.
|
||||
InstanceDbResolver.IsInstanceDbType("Instance DB").ShouldBeTrue();
|
||||
InstanceDbResolver.IsInstanceDbType("instance db").ShouldBeTrue();
|
||||
InstanceDbResolver.IsInstanceDbType("Instance").ShouldBeTrue();
|
||||
InstanceDbResolver.IsInstanceDbType("Instance Data Block").ShouldBeTrue();
|
||||
InstanceDbResolver.IsInstanceDbType("instance-db").ShouldBeTrue();
|
||||
InstanceDbResolver.IsInstanceDbType("\"Instance DB\"").ShouldBeTrue(); // quotes survive
|
||||
// German export variants.
|
||||
InstanceDbResolver.IsInstanceDbType("Instanz-DB").ShouldBeTrue();
|
||||
InstanceDbResolver.IsInstanceDbType("Instanz-Datenbaustein").ShouldBeTrue();
|
||||
// Negative cases — Global rows must NOT be confused for instance DBs.
|
||||
InstanceDbResolver.IsInstanceDbType("Global DB").ShouldBeFalse();
|
||||
InstanceDbResolver.IsInstanceDbType("Global").ShouldBeFalse();
|
||||
InstanceDbResolver.IsInstanceDbType("").ShouldBeFalse();
|
||||
InstanceDbResolver.IsInstanceDbType(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsGlobalDbType_recognises_canonical_and_locale_variants()
|
||||
{
|
||||
InstanceDbResolver.IsGlobalDbType("Global DB").ShouldBeTrue();
|
||||
InstanceDbResolver.IsGlobalDbType("global").ShouldBeTrue();
|
||||
InstanceDbResolver.IsGlobalDbType("Global Data Block").ShouldBeTrue();
|
||||
InstanceDbResolver.IsGlobalDbType("Globaler Datenbaustein").ShouldBeTrue();
|
||||
InstanceDbResolver.IsGlobalDbType("Instance DB").ShouldBeFalse();
|
||||
InstanceDbResolver.IsGlobalDbType("").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolve_with_resolved_address_returns_tag_at_that_address()
|
||||
{
|
||||
var resolver = new InstanceDbResolver(NullLogger<InstanceDbResolver>.Instance);
|
||||
var row = new InstanceDbRow(
|
||||
Name: "MotorFB_1.Speed",
|
||||
DbType: "Instance DB",
|
||||
LogicalAddress: "%DB7.DBW0",
|
||||
DataType: "Int");
|
||||
|
||||
resolver.TryResolve(row, out var tag).ShouldBeTrue();
|
||||
tag.ShouldNotBeNull();
|
||||
tag!.Name.ShouldBe("MotorFB_1.Speed");
|
||||
tag.Address.ShouldBe("DB7.DBW0");
|
||||
tag.DataType.ShouldBe(S7DataType.Int16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolve_with_empty_address_but_interface_offsets_computes_address()
|
||||
{
|
||||
// FB interface declares Speed at member offset 4 within an FB instance whose base
|
||||
// sits at offset 8 inside DB12. Resolver computes DB12.DBW(8 + 4) = DB12.DBW12.
|
||||
var resolver = new InstanceDbResolver(NullLogger<InstanceDbResolver>.Instance);
|
||||
var row = new InstanceDbRow(
|
||||
Name: "PumpFB_2.Speed",
|
||||
DbType: "Instance DB",
|
||||
LogicalAddress: null,
|
||||
DataType: "Int",
|
||||
ParentDbNumber: 12,
|
||||
ParentBaseOffset: 8,
|
||||
MemberOffset: 4);
|
||||
|
||||
resolver.TryResolve(row, out var tag).ShouldBeTrue();
|
||||
tag.ShouldNotBeNull();
|
||||
tag!.Address.ShouldBe("DB12.DBW12");
|
||||
tag.DataType.ShouldBe(S7DataType.Int16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolve_with_empty_address_for_bool_uses_bit_offset()
|
||||
{
|
||||
// BOOL members carry a bit offset; resolver builds DBn.DBX{byte}.{bit}.
|
||||
var resolver = new InstanceDbResolver(NullLogger<InstanceDbResolver>.Instance);
|
||||
var row = new InstanceDbRow(
|
||||
Name: "PumpFB_2.Enabled",
|
||||
DbType: "Instance DB",
|
||||
LogicalAddress: null,
|
||||
DataType: "Bool",
|
||||
ParentDbNumber: 12,
|
||||
ParentBaseOffset: 0,
|
||||
MemberOffset: 6,
|
||||
MemberBitOffset: 3);
|
||||
|
||||
resolver.TryResolve(row, out var tag).ShouldBeTrue();
|
||||
tag.ShouldNotBeNull();
|
||||
tag!.Address.ShouldBe("DB12.DBX6.3");
|
||||
tag.DataType.ShouldBe(S7DataType.Bool);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolve_with_no_address_and_no_interface_info_fails()
|
||||
{
|
||||
var resolver = new InstanceDbResolver(NullLogger<InstanceDbResolver>.Instance);
|
||||
var row = new InstanceDbRow(
|
||||
Name: "Orphan.Member",
|
||||
DbType: "Instance DB",
|
||||
LogicalAddress: null,
|
||||
DataType: "Int");
|
||||
|
||||
resolver.TryResolve(row, out var tag).ShouldBeFalse();
|
||||
tag.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolve_with_unparseable_address_fails()
|
||||
{
|
||||
var resolver = new InstanceDbResolver(NullLogger<InstanceDbResolver>.Instance);
|
||||
var row = new InstanceDbRow(
|
||||
Name: "Garbage",
|
||||
DbType: "Instance DB",
|
||||
LogicalAddress: "%XYZ-not-an-address",
|
||||
DataType: "Int");
|
||||
|
||||
resolver.TryResolve(row, out var tag).ShouldBeFalse();
|
||||
tag.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_five_instance_db_rows_with_resolved_addresses_yield_five_tags()
|
||||
{
|
||||
// Five instance-DB members under the same MotorFB_Instance, fully resolved by TIA.
|
||||
const string csv = """
|
||||
Name,Path,Data type,Logical address,Comment,Hmi accessible,DB type
|
||||
MotorFB_1.Speed,FB,Int,%DB7.DBW0,Speed setpoint,True,Instance DB
|
||||
MotorFB_1.Accel,FB,Int,%DB7.DBW2,Acceleration ramp,True,Instance DB
|
||||
MotorFB_1.Setpoint,FB,Real,%DB7.DBD4,Position setpoint,True,Instance DB
|
||||
MotorFB_1.Enabled,FB,Bool,%DB7.DBX8.0,Drive enabled bit,True,Instance DB
|
||||
MotorFB_1.Fault,FB,Bool,%DB7.DBX8.1,Fault bit,True,Instance DB
|
||||
""";
|
||||
|
||||
var result = ParseString(csv);
|
||||
result.ParsedCount.ShouldBe(5);
|
||||
result.InstanceDbCount.ShouldBe(5);
|
||||
result.UdtPlaceholderCount.ShouldBe(0);
|
||||
result.SkippedCount.ShouldBe(0);
|
||||
result.ErrorCount.ShouldBe(0);
|
||||
|
||||
result.Tags[0].Address.ShouldBe("DB7.DBW0");
|
||||
result.Tags[0].DataType.ShouldBe(S7DataType.Int16);
|
||||
result.Tags[1].Address.ShouldBe("DB7.DBW2");
|
||||
result.Tags[2].Address.ShouldBe("DB7.DBD4");
|
||||
result.Tags[2].DataType.ShouldBe(S7DataType.Float32);
|
||||
result.Tags[3].Address.ShouldBe("DB7.DBX8.0");
|
||||
result.Tags[3].DataType.ShouldBe(S7DataType.Bool);
|
||||
result.Tags[4].Address.ShouldBe("DB7.DBX8.1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_mixed_global_and_instance_rows_handles_both()
|
||||
{
|
||||
// Two global-DB rows (no DB type column → treated as Global by D1) + three
|
||||
// instance-DB rows (DB type=Instance DB → routed through the resolver).
|
||||
const string csv = """
|
||||
Name,Path,Data type,Logical address,Comment,Hmi accessible,DB type
|
||||
ProbeWord,Tags,UInt,%MW0,Probe,True,
|
||||
Setpoint,Tags,Real,%DB1.DBD4,Setpoint,True,Global DB
|
||||
FB1.Speed,FB,Int,%DB7.DBW0,FB speed,True,Instance DB
|
||||
FB1.Accel,FB,Int,%DB7.DBW2,FB accel,True,Instance DB
|
||||
FB1.Enabled,FB,Bool,%DB7.DBX8.0,FB enabled,True,Instance DB
|
||||
""";
|
||||
|
||||
var result = ParseString(csv);
|
||||
result.ParsedCount.ShouldBe(5);
|
||||
result.InstanceDbCount.ShouldBe(3);
|
||||
result.SkippedCount.ShouldBe(0);
|
||||
result.ErrorCount.ShouldBe(0);
|
||||
result.UdtPlaceholderCount.ShouldBe(0);
|
||||
|
||||
// First two rows are global; last three are instance.
|
||||
result.Tags[0].Name.ShouldBe("ProbeWord");
|
||||
result.Tags[1].Name.ShouldBe("Setpoint");
|
||||
result.Tags[2].Name.ShouldBe("FB1.Speed");
|
||||
result.Tags[3].Name.ShouldBe("FB1.Accel");
|
||||
result.Tags[4].Name.ShouldBe("FB1.Enabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_instance_db_with_locale_variants_are_all_recognised()
|
||||
{
|
||||
// Verify every locale variant of the DB type column gets routed through the
|
||||
// resolver. One row per variant; all five must land in InstanceDbCount.
|
||||
const string csv = """
|
||||
Name,Path,Data type,Logical address,Comment,Hmi accessible,DB type
|
||||
T1,FB,Int,%DB10.DBW0,t1,True,Instance
|
||||
T2,FB,Int,%DB10.DBW2,t2,True,Instance DB
|
||||
T3,FB,Int,%DB10.DBW4,t3,True,Instance-DB
|
||||
T4,FB,Int,%DB10.DBW6,t4,True,Instance Data Block
|
||||
T5,FB,Int,%DB10.DBW8,t5,True,Instanz-DB
|
||||
""";
|
||||
|
||||
var result = ParseString(csv);
|
||||
result.ParsedCount.ShouldBe(5);
|
||||
result.InstanceDbCount.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_data_type_resolution_unchanged_from_d1_for_instance_rows()
|
||||
{
|
||||
// Sanity: instance-DB rows go through the same TryResolveDataType path as global
|
||||
// rows, so the existing primitive-type table is honoured. Spot-check the wider
|
||||
// set: Bool, Int, DInt, Real, String.
|
||||
const string csv = """
|
||||
Name,Path,Data type,Logical address,Comment,Hmi accessible,DB type,Length
|
||||
FB.Bit,FB,Bool,%DB1.DBX0.0,b,True,Instance DB,
|
||||
FB.I16,FB,Int,%DB1.DBW2,i16,True,Instance DB,
|
||||
FB.I32,FB,DInt,%DB1.DBD4,i32,True,Instance DB,
|
||||
FB.F32,FB,Real,%DB1.DBD8,f32,True,Instance DB,
|
||||
FB.Str,FB,String,%DB1.DBB12,s,True,Instance DB,32
|
||||
""";
|
||||
|
||||
var result = ParseString(csv);
|
||||
result.ParsedCount.ShouldBe(5);
|
||||
result.InstanceDbCount.ShouldBe(5);
|
||||
|
||||
result.Tags[0].DataType.ShouldBe(S7DataType.Bool);
|
||||
result.Tags[1].DataType.ShouldBe(S7DataType.Int16);
|
||||
result.Tags[2].DataType.ShouldBe(S7DataType.Int32);
|
||||
result.Tags[3].DataType.ShouldBe(S7DataType.Float32);
|
||||
result.Tags[4].DataType.ShouldBe(S7DataType.String);
|
||||
result.Tags[4].StringLength.ShouldBe(32);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_global_rows_still_count_zero_instance_db()
|
||||
{
|
||||
// Negative regression — a CSV without any Instance-DB rows must report
|
||||
// InstanceDbCount = 0 even when the DB type column is present.
|
||||
const string csv = """
|
||||
Name,Path,Data type,Logical address,Comment,Hmi accessible,DB type
|
||||
A,T,Int,%MW0,a,True,Global DB
|
||||
B,T,Real,%DB1.DBD4,b,True,Global DB
|
||||
""";
|
||||
|
||||
var result = ParseString(csv);
|
||||
result.ParsedCount.ShouldBe(2);
|
||||
result.InstanceDbCount.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user