v3(s7): apply Modbus RawTags exemplar to the S7 driver
Rename S7EquipmentTagParser -> S7TagDefinitionFactory; TryParse(reference)
-> FromTagConfig(tagConfig, rawPath, out def) (drops the leading-{ heuristic,
keys the def by RawPath, adds inverse ToTagConfig). Replace S7DriverOptions.Tags
(pre-declared S7TagDefinition list) with RawTags (IReadOnlyList<RawTagEntry>);
the driver builds its RawPath->def table from RawTags at Initialize via the
factory (skip+log on miss), resolver is byName-only, DiscoverAsync + init guards
+ address pre-parse now run off that table. Factory retires the pre-declared DTO
tag path (RawTags binding only). Cli BuildOptions serialises typed defs to
RawTagEntry. Tests migrated to a S7RawTags helper + FromTagConfig; parser test
files renamed. Coordinator's EquipmentTagConfigInspector S7 rename left to Wave-B
coordinator; Driver.S7.IntegrationTests (Docker-gated) left red per scope.
Driver.S7 + Cli build green; Driver.S7.Tests 264/264, S7.Cli.Tests 49/49.
This commit is contained in:
+13
-4
@@ -87,16 +87,25 @@ public sealed class S7CommandBaseBuildOptionsTests
|
||||
options.Slot.ShouldBe((short)2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that BuildOptions forwards the tag list verbatim.</summary>
|
||||
/// <summary>Verifies that BuildOptions serialises each typed tag to a RawTagEntry (RawPath =
|
||||
/// the def's Name) whose TagConfig round-trips back to the same definition (v3 delivery shape).</summary>
|
||||
[Fact]
|
||||
public void BuildOptions_forwards_tag_list_verbatim()
|
||||
public void BuildOptions_serialises_tags_to_raw_tag_entries()
|
||||
{
|
||||
var sut = new ProbeOnly { Host = "h" };
|
||||
var tag = new S7TagDefinition("t", "MW0", S7DataType.Int16, Writable: false);
|
||||
|
||||
var options = sut.Invoke([tag]);
|
||||
|
||||
options.Tags.Count.ShouldBe(1);
|
||||
options.Tags[0].ShouldBeSameAs(tag);
|
||||
options.RawTags.Count.ShouldBe(1);
|
||||
var entry = options.RawTags[0];
|
||||
entry.RawPath.ShouldBe("t");
|
||||
entry.WriteIdempotent.ShouldBe(tag.WriteIdempotent);
|
||||
|
||||
S7TagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var round).ShouldBeTrue();
|
||||
round!.Name.ShouldBe("t");
|
||||
round.Address.ShouldBe("MW0");
|
||||
round.DataType.ShouldBe(S7DataType.Int16);
|
||||
round.Writable.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,13 +180,15 @@ public sealed class S7ArrayReadTests
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Tags =
|
||||
[
|
||||
new("Scalar", "DB1.DBW0", S7DataType.Int16),
|
||||
new("Arr", "DB1.DBW10", S7DataType.Int16, ArrayCount: 8),
|
||||
],
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
// Array tags are read-only this phase (writable arrays are rejected by the init guard).
|
||||
RawTags = S7RawTags.Entries(
|
||||
new S7TagDefinition("Scalar", "DB1.DBW0", S7DataType.Int16),
|
||||
new S7TagDefinition("Arr", "DB1.DBW10", S7DataType.Int16, Writable: false, ArrayCount: 8)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-arr-disco");
|
||||
// v3: DiscoverAsync projects the table built at Initialize — initialize with a fake PLC first.
|
||||
using var drv = new S7Driver(opts, "s7-arr-disco", new ConnectingFakeS7PlcFactory());
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||
@@ -209,9 +211,12 @@ public sealed class S7ArrayReadTests
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Tags = [new("One", "DB1.DBW0", S7DataType.Int16, ArrayCount: 1)],
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("One", "DB1.DBW0", S7DataType.Int16, Writable: false, ArrayCount: 1)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-arr-one");
|
||||
// v3: DiscoverAsync projects the table built at Initialize — initialize with a fake PLC first.
|
||||
using var drv = new S7Driver(opts, "s7-arr-one", new ConnectingFakeS7PlcFactory());
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||
@@ -228,9 +233,12 @@ public sealed class S7ArrayReadTests
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Tags = [new("Scalar", "DB1.DBW0", S7DataType.Int16, ArrayCount: null)],
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("Scalar", "DB1.DBW0", S7DataType.Int16, ArrayCount: null)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-arr-null");
|
||||
// v3: DiscoverAsync projects the table built at Initialize — initialize with a fake PLC first.
|
||||
using var drv = new S7Driver(opts, "s7-arr-null", new ConnectingFakeS7PlcFactory());
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||
@@ -253,32 +261,33 @@ public sealed class S7ArrayReadTests
|
||||
arr[0].ShouldBe((short)100);
|
||||
}
|
||||
|
||||
// ── Equipment-tag resolver threads arrayLength → ArrayCount ───────────────────────────
|
||||
// ── Tag-definition factory threads arrayLength → ArrayCount ───────────────────────────
|
||||
|
||||
/// <summary>Verifies the equipment-tag parser threads isArray/arrayLength into ArrayCount.</summary>
|
||||
/// <summary>Verifies the tag-definition factory threads isArray/arrayLength into ArrayCount.</summary>
|
||||
[Fact]
|
||||
public void EquipmentTagParser_threads_array_length_into_ArrayCount()
|
||||
public void TagDefinitionFactory_threads_array_length_into_ArrayCount()
|
||||
{
|
||||
var json = """{"address":"DB1.DBW0","dataType":"Int16","isArray":true,"arrayLength":16}""";
|
||||
S7EquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
|
||||
def!.ArrayCount.ShouldBe(16);
|
||||
S7TagDefinitionFactory.FromTagConfig(json, "line/eq/arr", out var def).ShouldBeTrue();
|
||||
def!.Name.ShouldBe("line/eq/arr", "the definition's identity is the RawPath");
|
||||
def.ArrayCount.ShouldBe(16);
|
||||
}
|
||||
|
||||
/// <summary>Verifies arrayLength is ignored when isArray is false (mirrors the sink foundation).</summary>
|
||||
[Fact]
|
||||
public void EquipmentTagParser_ignores_arrayLength_when_isArray_false()
|
||||
public void TagDefinitionFactory_ignores_arrayLength_when_isArray_false()
|
||||
{
|
||||
var json = """{"address":"DB1.DBW0","dataType":"Int16","isArray":false,"arrayLength":16}""";
|
||||
S7EquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
|
||||
S7TagDefinitionFactory.FromTagConfig(json, "line/eq/arr", out var def).ShouldBeTrue();
|
||||
def!.ArrayCount.ShouldBeNull("arrayLength is honoured only when isArray is true");
|
||||
}
|
||||
|
||||
/// <summary>Verifies a scalar equipment tag (no array keys) has a null ArrayCount.</summary>
|
||||
/// <summary>Verifies a scalar tag config (no array keys) has a null ArrayCount.</summary>
|
||||
[Fact]
|
||||
public void EquipmentTagParser_scalar_has_null_ArrayCount()
|
||||
public void TagDefinitionFactory_scalar_has_null_ArrayCount()
|
||||
{
|
||||
var json = """{"address":"DB1.DBW0","dataType":"Int16"}""";
|
||||
S7EquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
|
||||
S7TagDefinitionFactory.FromTagConfig(json, "line/eq/scalar", out var def).ShouldBeTrue();
|
||||
def!.ArrayCount.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,14 +22,16 @@ public sealed class S7DiscoveryAndSubscribeTests
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Tags =
|
||||
[
|
||||
new("TempSetpoint", "DB1.DBW0", S7DataType.Int16, Writable: true),
|
||||
new("FaultBit", "M0.0", S7DataType.Bool, Writable: false),
|
||||
new("PIDOutput", "DB5.DBD12", S7DataType.Float32, Writable: true),
|
||||
],
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
RawTags = S7RawTags.Entries(
|
||||
new S7TagDefinition("TempSetpoint", "DB1.DBW0", S7DataType.Int16, Writable: true),
|
||||
new S7TagDefinition("FaultBit", "M0.0", S7DataType.Bool, Writable: false),
|
||||
new S7TagDefinition("PIDOutput", "DB5.DBD12", S7DataType.Float32, Writable: true)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-disco");
|
||||
// v3: DiscoverAsync projects the RawPath → definition table, which is built at Initialize —
|
||||
// so the driver must be initialized (with a connecting fake PLC) before Discover.
|
||||
using var drv = new S7Driver(opts, "s7-disco", new ConnectingFakeS7PlcFactory());
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||
@@ -49,13 +51,14 @@ public sealed class S7DiscoveryAndSubscribeTests
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Tags =
|
||||
[
|
||||
new("SetPoint", "DB1.DBW0", S7DataType.Int16, WriteIdempotent: true),
|
||||
new("StartBit", "M0.0", S7DataType.Bool),
|
||||
],
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
RawTags = S7RawTags.Entries(
|
||||
new S7TagDefinition("SetPoint", "DB1.DBW0", S7DataType.Int16, WriteIdempotent: true),
|
||||
new S7TagDefinition("StartBit", "M0.0", S7DataType.Bool)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-idem");
|
||||
// v3: DiscoverAsync projects the table built at Initialize — initialize with a fake PLC first.
|
||||
using var drv = new S7Driver(opts, "s7-idem", new ConnectingFakeS7PlcFactory());
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||
|
||||
@@ -34,7 +34,7 @@ public sealed class S7DriverCodeReviewFixTests
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Tags = [new S7TagDefinition("Quirk", address, S7DataType.Int16)],
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("Quirk", address, S7DataType.Int16)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-tc");
|
||||
|
||||
@@ -58,11 +58,9 @@ public sealed class S7DriverCodeReviewFixTests
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Tags =
|
||||
[
|
||||
RawTags = S7RawTags.Entries(
|
||||
new S7TagDefinition("Word", "DB1.DBW0", S7DataType.Int16),
|
||||
new S7TagDefinition("Bit", "M0.0", S7DataType.Bool),
|
||||
],
|
||||
new S7TagDefinition("Bit", "M0.0", S7DataType.Bool)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-ok");
|
||||
|
||||
@@ -111,7 +109,7 @@ public sealed class S7DriverCodeReviewFixTests
|
||||
|
||||
const string json = """
|
||||
{ "Host": "192.0.2.1", "TimeoutMs": 250,
|
||||
"Tags": [ { "Name": "TimerTag", "Address": "T5", "DataType": "Int16" } ] }
|
||||
"RawTags": [ { "RawPath": "TimerTag", "TagConfig": "{\"address\":\"T5\",\"dataType\":\"Int16\"}", "WriteIdempotent": false } ] }
|
||||
""";
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
|
||||
@@ -165,7 +165,7 @@ public sealed class S7DriverCodeReviewFixTests2
|
||||
// DB1.DBD0 is a DWord (non-byte) address — guard-(b) must fault on the address shape
|
||||
// for a wide type. (At a byte address like DB1.DBB0 the 8-byte numerics round-trip;
|
||||
// see S7ScalarBlockTests.)
|
||||
Tags = [new S7TagDefinition("X", "DB1.DBD0", dt)],
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("X", "DB1.DBD0", dt)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, $"s7-wide-nonbyte-{dt}");
|
||||
|
||||
@@ -197,7 +197,7 @@ public sealed class S7DriverCodeReviewFixTests2
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Tags = [new S7TagDefinition("X", addr, dt)],
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("X", addr, dt)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, $"s7-good-dt-{dt}");
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ public sealed class S7DriverReadWriteTests
|
||||
{
|
||||
Host = "192.0.2.1", // reserved — will never complete TCP handshake
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Tags = [new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16)],
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-bad-tag");
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ public sealed class S7DriverReconnectTests
|
||||
// Probe off — these tests drive reconnect through the read path deterministically; a
|
||||
// background probe would race the created-connection assertions.
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
Tags = [new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)],
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -265,7 +265,7 @@ public sealed class S7DriverReconnectTests
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromSeconds(5), // long, so the CALLER token fires first, not the internal timeout
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
Tags = [new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)],
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)),
|
||||
};
|
||||
var factory = new FakeS7PlcFactory();
|
||||
using var drv = new S7Driver(opts, "s7-caller-cancel", factory);
|
||||
@@ -367,7 +367,7 @@ public sealed class S7DriverReconnectTests
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
Tags = [new S7TagDefinition("WW0", "DB1.DBW0", S7DataType.UInt16, Writable: true)],
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("WW0", "DB1.DBW0", S7DataType.UInt16, Writable: true)),
|
||||
};
|
||||
var factory = new FakeS7PlcFactory();
|
||||
using var drv = new S7Driver(opts, "s7-cancel-write", factory);
|
||||
@@ -448,7 +448,7 @@ public sealed class S7DriverReconnectTests
|
||||
Interval = TimeSpan.FromMilliseconds(50),
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
},
|
||||
Tags = [new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)],
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)),
|
||||
};
|
||||
|
||||
/// <summary>Polls <paramref name="condition"/> until it holds or <paramref name="timeout"/> elapses (returns either way).</summary>
|
||||
|
||||
@@ -85,7 +85,7 @@ public sealed class S7DriverScaffoldTests
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Tags = [new S7TagDefinition("LReal", "DB1.DBB0", S7DataType.Float64)],
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("LReal", "DB1.DBB0", S7DataType.Float64)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-wide-ok");
|
||||
|
||||
@@ -105,7 +105,7 @@ public sealed class S7DriverScaffoldTests
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Tags = [new S7TagDefinition("Batch64", "DB1.DBB0", S7DataType.Int64, ArrayCount: 4)],
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("Batch64", "DB1.DBB0", S7DataType.Int64, ArrayCount: 4)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-wide-array");
|
||||
|
||||
@@ -125,7 +125,7 @@ public sealed class S7DriverScaffoldTests
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Tags = [new S7TagDefinition("WideWord", "DB1.DBW0", S7DataType.Float64)],
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("WideWord", "DB1.DBW0", S7DataType.Float64)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-wide-nonbyte");
|
||||
|
||||
@@ -145,7 +145,7 @@ public sealed class S7DriverScaffoldTests
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Tags = [new S7TagDefinition("Timer5", "T5", S7DataType.Int32)],
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("Timer5", "T5", S7DataType.Int32)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-timer-bad");
|
||||
|
||||
@@ -165,7 +165,7 @@ public sealed class S7DriverScaffoldTests
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Tags = [new S7TagDefinition("Counter3", "C3", S7DataType.Float32)],
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("Counter3", "C3", S7DataType.Float32)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-counter-bad");
|
||||
|
||||
@@ -192,7 +192,7 @@ public sealed class S7DriverScaffoldTests
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Tags = [new S7TagDefinition("Timer5", "T5", S7DataType.Float64, Writable: true)],
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("Timer5", "T5", S7DataType.Float64, Writable: true)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-timer-writable");
|
||||
|
||||
@@ -213,7 +213,7 @@ public sealed class S7DriverScaffoldTests
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Tags = [new S7TagDefinition("Counter3", "C3", S7DataType.Int32, Writable: true)],
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("Counter3", "C3", S7DataType.Int32, Writable: true)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-counter-writable");
|
||||
|
||||
@@ -250,7 +250,7 @@ public sealed class S7DriverScaffoldTests
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Tags = [new S7TagDefinition("ArrTag", addr, dt, Writable: true, ArrayCount: count)],
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("ArrTag", addr, dt, Writable: true, ArrayCount: count)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, $"s7-writable-arr-{dt}");
|
||||
|
||||
@@ -272,7 +272,7 @@ public sealed class S7DriverScaffoldTests
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Tags = [new S7TagDefinition("ReadArr", "DB1.DBW0", S7DataType.Int16, Writable: false, ArrayCount: 4)],
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("ReadArr", "DB1.DBW0", S7DataType.Int16, Writable: false, ArrayCount: 4)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-readonly-arr");
|
||||
|
||||
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>R2-11 Phase C strictness surface for the S7 equipment-tag parser: the runtime is now STRICT — a
|
||||
/// typo'd <c>dataType</c> rejects the tag (<c>TryParse</c> ⇒ <see langword="false"/> ⇒ <c>BadNodeIdUnknown</c>),
|
||||
/// <c>Inspect</c> reports the typo, and the <c>writable</c> key is honoured (default true).</summary>
|
||||
public sealed class S7EquipmentTagParserStrictnessTests
|
||||
{
|
||||
[Fact]
|
||||
public void Typo_dataType_rejects_the_tag()
|
||||
{
|
||||
S7EquipmentTagParser.TryParse("{\"address\":\"DB1.DBW0\",\"dataType\":\"Intt16\"}", out _)
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Valid_dataType_still_parses()
|
||||
{
|
||||
S7EquipmentTagParser.TryParse("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}", out var def)
|
||||
.ShouldBeTrue();
|
||||
def.DataType.ShouldBe(S7DataType.Int16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_reports_typo_dataType()
|
||||
{
|
||||
var warnings = S7EquipmentTagParser.Inspect("{\"address\":\"DB1.DBW0\",\"dataType\":\"Intt16\"}");
|
||||
warnings.ShouldHaveSingleItem();
|
||||
warnings[0].ShouldContain("Intt16");
|
||||
warnings[0].ShouldContain("dataType");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_clean_config_has_no_warnings()
|
||||
{
|
||||
S7EquipmentTagParser.Inspect("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Writable_false_is_honoured()
|
||||
{
|
||||
S7EquipmentTagParser.TryParse("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\",\"writable\":false}", out var def)
|
||||
.ShouldBeTrue();
|
||||
def.Writable.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Writable_absent_defaults_to_true()
|
||||
{
|
||||
S7EquipmentTagParser.TryParse("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}", out var def)
|
||||
.ShouldBeTrue();
|
||||
def.Writable.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class S7EquipmentTagTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parses_equipment_tagconfig_into_a_transient_definition()
|
||||
{
|
||||
var json = """{"address":"DB1.DBW0","dataType":"UInt16","stringLength":0}""";
|
||||
S7EquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
|
||||
def!.Name.ShouldBe(json);
|
||||
def.Address.ShouldBe("DB1.DBW0");
|
||||
def.DataType.ShouldBe(S7DataType.UInt16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_a_non_address_blob()
|
||||
=> S7EquipmentTagParser.TryParse("""{"FullName":"x"}""", out _).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void Rejects_garbage()
|
||||
=> S7EquipmentTagParser.TryParse("not json", out _).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void Rejects_address_as_a_json_number()
|
||||
=> S7EquipmentTagParser.TryParse(
|
||||
"""{"address":40001,"dataType":"UInt16"}""", out _).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void Rejects_string_length_out_of_range()
|
||||
=> S7EquipmentTagParser.TryParse(
|
||||
"""{"address":"DB1.DBW0","dataType":"String","stringLength":300}""", out _).ShouldBeFalse();
|
||||
}
|
||||
@@ -58,7 +58,7 @@ public sealed class S7PollEngineMigrationTests
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
Tags = [new S7TagDefinition("Arr", "DB1.DBW0", S7DataType.UInt16, Writable: false, ArrayCount: 3)],
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("Arr", "DB1.DBW0", S7DataType.UInt16, Writable: false, ArrayCount: 3)),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Test helper bridging the v2 authoring shape (a typed <see cref="S7TagDefinition"/>) to the v3
|
||||
/// delivery shape (<see cref="RawTagEntry"/>). Under v3 the driver no longer takes pre-declared
|
||||
/// definitions — the deploy artifact hands it authored raw tags (RawPath + TagConfig blob), and the
|
||||
/// driver rebuilds the typed definitions via <see cref="S7TagDefinitionFactory"/>. These tests keep
|
||||
/// expressing their intent as typed definitions; this helper serialises each back to its TagConfig blob
|
||||
/// (via <see cref="S7TagDefinitionFactory.ToTagConfig"/>) and packages it as a <see cref="RawTagEntry"/>
|
||||
/// whose RawPath is the def's <c>Name</c> — so the round-trip through the real mapper reproduces the same
|
||||
/// definition the test authored, keyed by the same name the read/write/subscribe call sites use.
|
||||
/// </summary>
|
||||
internal static class S7RawTags
|
||||
{
|
||||
/// <summary>Serialises one typed definition into its <see cref="RawTagEntry"/> (RawPath = def.Name).</summary>
|
||||
/// <param name="def">The typed definition to convert.</param>
|
||||
/// <returns>The equivalent <see cref="RawTagEntry"/>.</returns>
|
||||
public static RawTagEntry Entry(S7TagDefinition def)
|
||||
=> new(def.Name, S7TagDefinitionFactory.ToTagConfig(def), def.WriteIdempotent);
|
||||
|
||||
/// <summary>Serialises a sequence of typed definitions into <see cref="RawTagEntry"/> records.</summary>
|
||||
/// <param name="defs">The typed definitions to convert.</param>
|
||||
/// <returns>The equivalent raw-tag entries.</returns>
|
||||
public static IReadOnlyList<RawTagEntry> Entries(params S7TagDefinition[] defs)
|
||||
=> [.. defs.Select(Entry)];
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>R2-11 Phase C strictness surface for the S7 tag-definition factory: the runtime is STRICT — a
|
||||
/// typo'd <c>dataType</c> rejects the tag (<c>FromTagConfig</c> ⇒ <see langword="false"/> ⇒ <c>BadNodeIdUnknown</c>),
|
||||
/// <c>Inspect</c> reports the typo, and the <c>writable</c> key is honoured (default true).</summary>
|
||||
public sealed class S7TagDefinitionFactoryStrictnessTests
|
||||
{
|
||||
[Fact]
|
||||
public void Typo_dataType_rejects_the_tag()
|
||||
{
|
||||
S7TagDefinitionFactory.FromTagConfig("{\"address\":\"DB1.DBW0\",\"dataType\":\"Intt16\"}", "p", out _)
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Valid_dataType_still_parses()
|
||||
{
|
||||
S7TagDefinitionFactory.FromTagConfig("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}", "line/eq/t", out var def)
|
||||
.ShouldBeTrue();
|
||||
def!.Name.ShouldBe("line/eq/t");
|
||||
def.DataType.ShouldBe(S7DataType.Int16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_reports_typo_dataType()
|
||||
{
|
||||
var warnings = S7TagDefinitionFactory.Inspect("{\"address\":\"DB1.DBW0\",\"dataType\":\"Intt16\"}");
|
||||
warnings.ShouldHaveSingleItem();
|
||||
warnings[0].ShouldContain("Intt16");
|
||||
warnings[0].ShouldContain("dataType");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_clean_config_has_no_warnings()
|
||||
{
|
||||
S7TagDefinitionFactory.Inspect("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Writable_false_is_honoured()
|
||||
{
|
||||
S7TagDefinitionFactory.FromTagConfig("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\",\"writable\":false}", "p", out var def)
|
||||
.ShouldBeTrue();
|
||||
def!.Writable.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Writable_absent_defaults_to_true()
|
||||
{
|
||||
S7TagDefinitionFactory.FromTagConfig("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}", "p", out var def)
|
||||
.ShouldBeTrue();
|
||||
def!.Writable.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class S7TagDefinitionFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Maps_tagconfig_into_a_definition_keyed_by_rawpath()
|
||||
{
|
||||
var json = """{"address":"DB1.DBW0","dataType":"UInt16","stringLength":0}""";
|
||||
S7TagDefinitionFactory.FromTagConfig(json, "line/eq/tag", out var def).ShouldBeTrue();
|
||||
def!.Name.ShouldBe("line/eq/tag", "the definition's identity is the RawPath, not the blob");
|
||||
def.Address.ShouldBe("DB1.DBW0");
|
||||
def.DataType.ShouldBe(S7DataType.UInt16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_a_non_address_blob()
|
||||
=> S7TagDefinitionFactory.FromTagConfig("""{"FullName":"x"}""", "p", out _).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void Rejects_garbage()
|
||||
=> S7TagDefinitionFactory.FromTagConfig("not json", "p", out _).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void Rejects_empty_tagconfig()
|
||||
=> S7TagDefinitionFactory.FromTagConfig("", "p", out _).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void Rejects_address_as_a_json_number()
|
||||
=> S7TagDefinitionFactory.FromTagConfig(
|
||||
"""{"address":40001,"dataType":"UInt16"}""", "p", out _).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void Rejects_string_length_out_of_range()
|
||||
=> S7TagDefinitionFactory.FromTagConfig(
|
||||
"""{"address":"DB1.DBW0","dataType":"String","stringLength":300}""", "p", out _).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void ToTagConfig_round_trips_through_FromTagConfig()
|
||||
{
|
||||
var original = new S7TagDefinition("line/eq/rt", "DB1.DBB0", S7DataType.String,
|
||||
Writable: false, StringLength: 32, ArrayCount: null);
|
||||
var blob = S7TagDefinitionFactory.ToTagConfig(original);
|
||||
|
||||
S7TagDefinitionFactory.FromTagConfig(blob, original.Name, out var round).ShouldBeTrue();
|
||||
round!.Name.ShouldBe(original.Name);
|
||||
round.Address.ShouldBe(original.Address);
|
||||
round.DataType.ShouldBe(original.DataType);
|
||||
round.Writable.ShouldBe(original.Writable);
|
||||
round.StringLength.ShouldBe(original.StringLength);
|
||||
round.ArrayCount.ShouldBe(original.ArrayCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToTagConfig_round_trips_an_array_definition()
|
||||
{
|
||||
var original = new S7TagDefinition("line/eq/arr", "DB1.DBW0", S7DataType.Int16,
|
||||
Writable: false, ArrayCount: 8);
|
||||
var blob = S7TagDefinitionFactory.ToTagConfig(original);
|
||||
|
||||
S7TagDefinitionFactory.FromTagConfig(blob, original.Name, out var round).ShouldBeTrue();
|
||||
round!.ArrayCount.ShouldBe(8);
|
||||
round.DataType.ShouldBe(S7DataType.Int16);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using S7NetDataType = global::S7.Net.DataType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
@@ -63,3 +64,42 @@ internal sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
=> throw new NotImplementedException("S7 driver never calls this — no alarm surfacing");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="IS7PlcFactory"/> whose connection opens cleanly and answers the CPU-status
|
||||
/// probe, but serves no data. Used by the tests that need the driver <c>Initialize</c>d — which in
|
||||
/// v3 is what builds the RawPath → definition table <see cref="S7Driver.DiscoverAsync"/> projects —
|
||||
/// without a live PLC or wire I/O.
|
||||
/// </summary>
|
||||
internal sealed class ConnectingFakeS7PlcFactory : IS7PlcFactory
|
||||
{
|
||||
/// <summary>Creates a fresh connecting fake connection.</summary>
|
||||
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
|
||||
=> new ConnectingFakeS7Plc();
|
||||
|
||||
private sealed class ConnectingFakeS7Plc : IS7Plc
|
||||
{
|
||||
/// <summary>Gets a value indicating whether the fake connection is open.</summary>
|
||||
public bool IsConnected { get; private set; }
|
||||
/// <summary>Opens the fake connection.</summary>
|
||||
public Task OpenAsync(CancellationToken ct) { IsConnected = true; return Task.CompletedTask; }
|
||||
/// <summary>Closes the fake connection.</summary>
|
||||
public void Close() => IsConnected = false;
|
||||
/// <summary>Not exercised by these tests.</summary>
|
||||
public Task<object?> ReadAsync(string address, CancellationToken ct)
|
||||
=> throw new NotSupportedException("ConnectingFakeS7Plc serves no data");
|
||||
/// <summary>Not exercised by these tests.</summary>
|
||||
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken ct)
|
||||
=> throw new NotSupportedException("ConnectingFakeS7Plc serves no data");
|
||||
/// <summary>Not exercised by these tests.</summary>
|
||||
public Task WriteAsync(string address, object value, CancellationToken ct)
|
||||
=> throw new NotSupportedException("ConnectingFakeS7Plc serves no data");
|
||||
/// <summary>Not exercised by these tests.</summary>
|
||||
public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken ct)
|
||||
=> throw new NotSupportedException("ConnectingFakeS7Plc serves no data");
|
||||
/// <summary>Answers the CPU-status probe.</summary>
|
||||
public Task ReadStatusAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
/// <summary>Disposes the fake connection.</summary>
|
||||
public void Dispose() => IsConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,9 +206,12 @@ public sealed class S7TypeMappingTests
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Tags = [new S7TagDefinition("Counter64", "DB1.DBB0", S7DataType.Int64)],
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("Counter64", "DB1.DBB0", S7DataType.Int64)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-i64-map");
|
||||
// v3: DiscoverAsync projects the table built at Initialize — initialize with a fake PLC first.
|
||||
using var drv = new S7Driver(opts, "s7-i64-map", new ConnectingFakeS7PlcFactory());
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||
@@ -224,9 +227,12 @@ public sealed class S7TypeMappingTests
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Tags = [new S7TagDefinition("Total64", "DB1.DBB0", S7DataType.UInt64)],
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("Total64", "DB1.DBB0", S7DataType.UInt64)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-u64-map");
|
||||
// v3: DiscoverAsync projects the table built at Initialize — initialize with a fake PLC first.
|
||||
using var drv = new S7Driver(opts, "s7-u64-map", new ConnectingFakeS7PlcFactory());
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||
|
||||
Reference in New Issue
Block a user