Merge master (7feffc96) into remed/lane-G1
Refs #743 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
+52
@@ -0,0 +1,52 @@
|
||||
using CliFx.Infrastructure;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U7: the AbLegacy probe/read/write commands had no <see cref="OperationCanceledException"/> arm, so a
|
||||
/// Ctrl-C printed a stack trace. They now run their driver work through
|
||||
/// <see cref="AbLegacyCommandBase.RunCancellableAsync"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CancellationHandlingTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Operator_cancellation_prints_Cancelled_instead_of_throwing()
|
||||
{
|
||||
using var console = new FakeInMemoryConsole();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Should.NotThrowAsync(async () => await AbLegacyCommandBase.RunCancellableAsync(
|
||||
console, cts.Token, () => Task.FromCanceled(cts.Token)));
|
||||
|
||||
console.ReadErrorString().ShouldContain("Cancelled.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_cancellation_the_operator_did_not_request_still_propagates()
|
||||
{
|
||||
using var console = new FakeInMemoryConsole();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () => await AbLegacyCommandBase.RunCancellableAsync(
|
||||
console, CancellationToken.None, () => throw new OperationCanceledException()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_completed_body_runs_to_completion()
|
||||
{
|
||||
using var console = new FakeInMemoryConsole();
|
||||
var ran = false;
|
||||
|
||||
await AbLegacyCommandBase.RunCancellableAsync(console, CancellationToken.None, () =>
|
||||
{
|
||||
ran = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
ran.ShouldBeTrue(); // positive control
|
||||
console.ReadErrorString().ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 (Gitea #737) against a running <c>ab_server</c> on both transports: a controller refusal is a per-tag
|
||||
/// config error, not a comms fault (H10 probe, H1/L4 data path), and a whole-array write round-trips (U10).
|
||||
/// Skipped when the fixture is unreachable (<see cref="AbServerTheoryAttribute"/>); the array write additionally
|
||||
/// needs <c>AB_SERVER_ALLOW_WRITES=1</c> (<see cref="IntegrationWriteGate"/>).
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Requires", "AbServer")]
|
||||
public sealed class AbCipRefusalLiveTests
|
||||
{
|
||||
private static string Device(AbServerFixture fixture) => $"ab://{fixture.Host}:{fixture.Port}/1,0";
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout, string what)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow > deadline) throw new TimeoutException(what);
|
||||
await Task.Delay(50);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// H10: a typo'd probe tag on a reachable controller leaves the host Running, and a COLD data tag created
|
||||
/// after many probe ticks still connects (the backoff window was never opened by the probe's NotFound).
|
||||
/// </summary>
|
||||
/// <param name="transport">The transport stack the driver runs on.</param>
|
||||
[AbServerTheory]
|
||||
[MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))]
|
||||
public async Task A_typod_probe_tag_does_not_black_out_a_reachable_device(AbCipTransport transport)
|
||||
{
|
||||
var device = Device(new AbServerFixture());
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(device, AbCipPlcFamily.ControlLogix)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Counter", device, "TestDINT", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
ProbeTagPath = "NoSuchProbeTag_xyz",
|
||||
Interval = TimeSpan.FromMilliseconds(200),
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
},
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Transport = transport,
|
||||
}, $"drv-h10-{transport}");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
try
|
||||
{
|
||||
await WaitUntilAsync(() => drv.GetHostStatuses().Single().State == HostState.Running,
|
||||
TimeSpan.FromSeconds(10), "the host never went Running on a refused probe tag");
|
||||
await Task.Delay(1_000); // several probe ticks — each one used to record a Forward-Open failure
|
||||
|
||||
var r = await drv.ReadAsync(["Counter"], CancellationToken.None); // a cold create
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// H1/L4: a tag the controller does not have reads BadNodeIdUnknown every poll while a sibling reads Good, and
|
||||
/// the driver stays Healthy with LastSuccessfulRead advancing (it used to latch Degraded and, with no other
|
||||
/// tag, trip the comms-loss watchdog against a reachable controller).
|
||||
/// </summary>
|
||||
/// <param name="transport">The transport stack the driver runs on.</param>
|
||||
[AbServerTheory]
|
||||
[MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))]
|
||||
public async Task A_missing_tag_is_per_tag_Bad_and_never_degrades_the_driver(AbCipTransport transport)
|
||||
{
|
||||
var device = Device(new AbServerFixture());
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(device, AbCipPlcFamily.ControlLogix)],
|
||||
RawTags = AbCipRawTags.From(
|
||||
new AbCipTagDefinition("Typo", device, "NoSuchTag_xyz", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("Counter", device, "TestDINT", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Transport = transport,
|
||||
}, $"drv-h1-{transport}");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
try
|
||||
{
|
||||
DateTime? last = null;
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var r = await drv.ReadAsync(["Typo", "Counter"], CancellationToken.None);
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
var health = drv.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Healthy);
|
||||
if (last is { } previous) health.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(previous);
|
||||
last = health.LastSuccessfulRead;
|
||||
}
|
||||
|
||||
// Only the refused tag: still Healthy, still answering (the watchdog conjunction can never trip).
|
||||
var before = drv.GetHealth().LastSuccessfulRead!.Value;
|
||||
await Task.Delay(20);
|
||||
(await drv.ReadAsync(["Typo"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// U10: a whole <c>int[]</c> written to the 16-element <c>TestArray</c> (DINT[16]) reads back element for element
|
||||
/// through a fresh driver; a wrong-length write is refused BadOutOfRange and changes nothing.
|
||||
/// </summary>
|
||||
/// <param name="transport">The transport stack the driver runs on.</param>
|
||||
[AbServerTheory]
|
||||
[MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))]
|
||||
public async Task A_whole_array_write_round_trips(AbCipTransport transport)
|
||||
{
|
||||
IntegrationWriteGate.SkipUnlessWritesAllowed();
|
||||
var device = Device(new AbServerFixture());
|
||||
var seed = Random.Shared.Next(1_000, 1_000_000);
|
||||
var values = Enumerable.Range(0, 16).Select(i => seed + i * 7).ToArray();
|
||||
|
||||
AbCipDriver Make(string id) => new(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(device, AbCipPlcFamily.ControlLogix)],
|
||||
RawTags = AbCipRawTags.From(
|
||||
new AbCipTagDefinition("Arr", device, "TestArray", AbCipDataType.DInt, ElementCount: 16, IsArray: true)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Transport = transport,
|
||||
}, $"drv-u10-{id}-{transport}");
|
||||
|
||||
var writer = Make("writer");
|
||||
await writer.InitializeAsync("{}", CancellationToken.None);
|
||||
try
|
||||
{
|
||||
var w = await writer.WriteAsync([new WriteRequest("Arr", values)], CancellationToken.None);
|
||||
w[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
var bad = await writer.WriteAsync([new WriteRequest("Arr", values.Take(15).ToArray())], CancellationToken.None);
|
||||
bad[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
|
||||
writer.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await writer.DisposeAsync();
|
||||
}
|
||||
|
||||
var reader = Make("reader");
|
||||
await reader.InitializeAsync("{}", CancellationToken.None);
|
||||
try
|
||||
{
|
||||
var r = await reader.ReadAsync(["Arr"], CancellationToken.None);
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
r[0].Value.ShouldBe(values);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await reader.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -311,8 +311,9 @@ public sealed class AbCipAlmaProjectionTests
|
||||
// A non-zero libplctag status ⇒ Bad on every member read; the driver evicts the handle and re-creates it, so
|
||||
// every re-created runtime must fail too (a fresh fake would read as "all clear").
|
||||
var recreations = 0;
|
||||
r.Factory.Customise = p => { Interlocked.Increment(ref recreations); return new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound }; };
|
||||
foreach (var t in r.Factory.Tags.Values.ToList()) t.Status = (int)AbStatus.NotFound;
|
||||
// A comms status (not a device refusal, which keeps the handle since review 07 H1) so the handle is evicted.
|
||||
r.Factory.Customise = p => { Interlocked.Increment(ref recreations); return new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection }; };
|
||||
foreach (var t in r.Factory.Tags.Values.ToList()) t.Status = (int)AbStatus.BadConnection;
|
||||
await WaitFor(() => Volatile.Read(ref recreations) >= 6);
|
||||
await r.StopAsync();
|
||||
Snapshot(r).Any(x => x.Kind == AlarmTransitionKind.Clear).ShouldBeFalse();
|
||||
@@ -334,9 +335,9 @@ public sealed class AbCipAlmaProjectionTests
|
||||
{
|
||||
if (!p.TagName.EndsWith("HAcked", StringComparison.Ordinal)) return null!;
|
||||
Interlocked.Increment(ref bad);
|
||||
return new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound };
|
||||
return new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection }; // comms ⇒ evicted + re-created
|
||||
};
|
||||
r.Factory.Tags[$"{Tag}.HAcked"].Status = (int)AbStatus.NotFound;
|
||||
r.Factory.Tags[$"{Tag}.HAcked"].Status = (int)AbStatus.BadConnection;
|
||||
await WaitFor(() => Volatile.Read(ref bad) >= 4);
|
||||
// The link recovers and HAcked reads true again.
|
||||
r.Factory.Customise = p => new FakeAbCipTag(p) { Value = true };
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U10: array writes. Config and the AdminUI allowed them and discovery advertised array nodes
|
||||
/// Operate-writable, but both runtimes encoded a SCALAR: an <c>int[]</c> threw InvalidCastException (BadTypeMismatch
|
||||
/// AND Degraded), a scalar wrote element 0 natively, and libplctag re-sent the whole last-read buffer with only
|
||||
/// element 0 patched. The driver now encodes <c>count</c> elements at the element width, refuses a length mismatch
|
||||
/// with <c>BadOutOfRange</c>, and converts element values (ScadaBridge writes <c>int[]</c> to INT arrays).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipArrayWriteTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)> BuildAsync(
|
||||
AbCipDataType type, int count, int size = -1)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Size = size } };
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Arr", Device, "Arr", type, ElementCount: count, IsArray: true)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "abcip-array-write", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
// ---- the codec ----
|
||||
|
||||
/// <summary>Each element is converted and laid out little-endian at the element width.</summary>
|
||||
[Fact]
|
||||
public void The_codec_converts_elements_and_lays_them_out_at_the_element_width()
|
||||
{
|
||||
AbCipArrayCodec.Encode(AbCipDataType.Int, [1, -2, 300]).ShouldBe(Convert.FromHexString("0100FEFF2C01"));
|
||||
AbCipArrayCodec.Encode(AbCipDataType.SInt, [(short)-1, 5L]).ShouldBe(Convert.FromHexString("FF05"));
|
||||
AbCipArrayCodec.Encode(AbCipDataType.DInt, [1.0, 2]).ShouldBe(Convert.FromHexString("0100000002000000"));
|
||||
AbCipArrayCodec.Encode(AbCipDataType.Real, [1, 2.5]).ShouldBe(Convert.FromHexString("0000803F00002040"));
|
||||
AbCipArrayCodec.Encode(AbCipDataType.ULInt, [ulong.MaxValue]).ShouldBe(Convert.FromHexString("FFFFFFFFFFFFFFFF"));
|
||||
Should.Throw<OverflowException>(() => AbCipArrayCodec.Encode(AbCipDataType.Int, [40000]));
|
||||
Should.Throw<NotSupportedException>(() => AbCipArrayCodec.Encode(AbCipDataType.Bool, [true]));
|
||||
}
|
||||
|
||||
// ---- the driver ----
|
||||
|
||||
/// <summary>ScadaBridge's wire shape: an <c>int[]</c> to an INT[3] encodes 3 two-byte elements and writes once, Good.</summary>
|
||||
[Fact]
|
||||
public async Task An_int_array_to_an_INT_array_encodes_every_element()
|
||||
{
|
||||
var (drv, factory) = await BuildAsync(AbCipDataType.Int, 3, size: 6);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
var tag = factory.Tags["Arr"];
|
||||
tag.EncodedArrayType.ShouldBe(AbCipDataType.Int);
|
||||
tag.EncodedArrayCount.ShouldBe(3);
|
||||
tag.EncodedArrayBytes.ShouldBe(Convert.FromHexString("010002000300"));
|
||||
tag.WriteCount.ShouldBe(1);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>A list of a different length is BadOutOfRange; nothing is created, encoded or written.</summary>
|
||||
/// <param name="length">The client's element count.</param>
|
||||
[Theory]
|
||||
[InlineData(2)]
|
||||
[InlineData(4)]
|
||||
public async Task A_length_mismatch_is_BadOutOfRange_and_nothing_is_written(int length)
|
||||
{
|
||||
var (drv, factory) = await BuildAsync(AbCipDataType.DInt, 3);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", Enumerable.Range(0, length).ToArray())], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
|
||||
factory.Tags.ShouldNotContainKey("Arr"); // refused before any runtime exists
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>A scalar (or a string) to an array tag is BadTypeMismatch — it used to write element 0 only.</summary>
|
||||
[Fact]
|
||||
public async Task A_scalar_to_an_array_tag_is_BadTypeMismatch()
|
||||
{
|
||||
var (drv, factory) = await BuildAsync(AbCipDataType.DInt, 3);
|
||||
|
||||
var r = await drv.WriteAsync(
|
||||
[new WriteRequest("Arr", 5), new WriteRequest("Arr", "1,2,3")], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
|
||||
r[1].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
|
||||
factory.Tags.ShouldNotContainKey("Arr");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>An element that does not fit is BadOutOfRange with no health write and nothing sent.</summary>
|
||||
[Fact]
|
||||
public async Task An_element_overflow_is_BadOutOfRange_and_nothing_is_sent()
|
||||
{
|
||||
var (drv, factory) = await BuildAsync(AbCipDataType.Int, 2, size: 4);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 70000 })], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
|
||||
factory.Tags["Arr"].WriteCount.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>BOOL / STRING arrays are refused BadNotSupported before any runtime exists.</summary>
|
||||
/// <param name="type">The array element type.</param>
|
||||
[Theory]
|
||||
[InlineData(AbCipDataType.Bool)]
|
||||
[InlineData(AbCipDataType.String)]
|
||||
public async Task Unencodable_element_types_are_BadNotSupported(AbCipDataType type)
|
||||
{
|
||||
var (drv, factory) = await BuildAsync(type, 2);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", new object[] { true, false })], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
|
||||
factory.Tags.ShouldNotContainKey("Arr");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy); // M5: a refusal writes no health
|
||||
}
|
||||
|
||||
/// <summary>The controller holding a different shape than authored (size ≠ width × count) is BadTypeMismatch, nothing sent.</summary>
|
||||
[Fact]
|
||||
public async Task A_controller_shape_mismatch_is_BadTypeMismatch_and_nothing_is_sent()
|
||||
{
|
||||
var (drv, factory) = await BuildAsync(AbCipDataType.Int, 3, size: 12); // DINT[3] on the controller
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
|
||||
factory.Tags["Arr"].WriteCount.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End to end over the REAL native runtime: an <c>int[]</c> to an INT[3] goes out as ONE Write Tag of type
|
||||
/// 0xC3 with element count 3 and the three 2-byte elements.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_native_array_write_is_one_write_of_count_elements()
|
||||
{
|
||||
await using var target = new FakeCipTarget();
|
||||
target.ConnectedHandler = req => Task.FromResult<byte[]?>(req[0] switch
|
||||
{
|
||||
0x52 => [0xD2, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], // INT[3] = 0,0,0
|
||||
0x4D => [0xCD, 0x00, 0x00, 0x00],
|
||||
_ => target.ServiceReply(req),
|
||||
});
|
||||
var host = $"ab://127.0.0.1:{target.Port}/1,0";
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Arr", host, "Arr", AbCipDataType.Int, ElementCount: 3, IsArray: true)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
}, "abcip-array-native", new NativeAbCipTagFactory(new DeviceSessionPool()));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
try
|
||||
{
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, -2, 300 })], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
var write = target.ConnectedCips.Single(c => c[0] == 0x4D);
|
||||
var typeAt = 2 + write[1] * 2;
|
||||
write.AsSpan(typeAt).ToArray().ShouldBe(Convert.FromHexString("C300" + "0300" + "0100FEFF2C01"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 H17: Logix allows a <c>.n</c> bit selector on SINT / INT / DINT (and LINT) hosts, and reads of
|
||||
/// <c>Flags.3</c> on an INT worked — but the bit write's read-modify-write hard-coded a DINT parent, so its
|
||||
/// <c>DecodeValue(DInt)</c> on a 2-byte buffer threw OutOfBounds and the write was reported as a comms fault
|
||||
/// (BadCommunicationError + Degraded). The parent width now comes from the parent read's size.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipBitInWordWidthTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)> BuildAsync(int parentSize, object parentValue, string bitPath)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbCipTag(p) { Value = parentValue, Size = parentSize },
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Bit", Device, bitPath, AbCipDataType.Bool)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "abcip-bit", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
/// <summary>SINT / INT / DINT hosts: the bit is set, every other bit is preserved, nothing degrades.</summary>
|
||||
/// <param name="size">The parent's wire width.</param>
|
||||
/// <param name="before">The parent's value before the write.</param>
|
||||
/// <param name="bit">The bit written true.</param>
|
||||
/// <param name="after">The value the parent must be written back with.</param>
|
||||
[Theory]
|
||||
[InlineData(1, 0x81, 3, 0x89)] // SINT 0x81 (-127): bit 7 set stays set, written as its unsigned byte
|
||||
[InlineData(1, 0x00, 7, 0x80)] // SINT top bit: 128 would overflow an sbyte encode
|
||||
[InlineData(2, 0x8001, 3, 0x8009)] // INT top bit preserved
|
||||
[InlineData(2, 0x0000, 15, 0x8000)] // INT top bit: 32768 would overflow a short encode
|
||||
[InlineData(4, 0x00000001, 3, 0x00000009)]
|
||||
public async Task A_bit_write_uses_the_parent_width(int size, int before, int bit, int after)
|
||||
{
|
||||
var (drv, factory) = await BuildAsync(size, before, $"Motor.Flags.{bit}");
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
Convert.ToInt64(factory.Tags["Motor.Flags"].Value).ShouldBe(after);
|
||||
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(1);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>A bit past the host's width is refused before anything is written, with no health write.</summary>
|
||||
[Fact]
|
||||
public async Task A_bit_past_the_host_width_is_BadOutOfRange_and_nothing_is_written()
|
||||
{
|
||||
var (drv, factory) = await BuildAsync(2, 0, "Motor.Flags.20");
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
|
||||
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>A host whose width is no integer's is a type mismatch, refused before writing, no health write.</summary>
|
||||
[Fact]
|
||||
public async Task A_host_of_no_integer_width_is_BadTypeMismatch()
|
||||
{
|
||||
var (drv, factory) = await BuildAsync(3, 0, "Motor.Flags.1");
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
|
||||
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End to end over the REAL native runtime against a fake CIP target whose <c>Flags</c> is an INT: the write
|
||||
/// goes out as ONE INT (type 0xC3, two data bytes) with the bit set and the other bits preserved.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_native_bit_write_on_an_INT_host_writes_an_INT()
|
||||
{
|
||||
await using var target = new FakeCipTarget();
|
||||
// Read Tag Fragmented (0x52) → INT (0x00C3) 0x8001; Write Tag (0x4D) → success.
|
||||
target.ConnectedHandler = req => Task.FromResult<byte[]?>(req[0] switch
|
||||
{
|
||||
0x52 => [0xD2, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x01, 0x80],
|
||||
0x4D => [0xCD, 0x00, 0x00, 0x00],
|
||||
_ => target.ServiceReply(req),
|
||||
});
|
||||
var host = $"ab://127.0.0.1:{target.Port}/1,0";
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Bit", host, "Flags.3", AbCipDataType.Bool)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
}, "abcip-bit-native", new NativeAbCipTagFactory(new DeviceSessionPool()));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
try
|
||||
{
|
||||
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
var write = target.ConnectedCips.Single(c => c[0] == 0x4D);
|
||||
var typeAt = 2 + write[1] * 2;
|
||||
write[typeAt].ShouldBe((byte)0xC3); // INT, not DINT
|
||||
write.Length.ShouldBe(typeAt + 2 + 2 + 2); // type, element count, ONE 2-byte element
|
||||
write[^2].ShouldBe((byte)0x09); // 0x8001 | bit 3
|
||||
write[^1].ShouldBe((byte)0x80);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M1: a parent read that fails with a COMMS status evicts the parent runtime and degrades (LastSuccessfulRead
|
||||
/// preserved); one the controller REFUSES is per-tag Bad, keeps the runtime and writes no health.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_failed_parent_read_follows_the_health_contract()
|
||||
{
|
||||
var (drv, factory) = await BuildAsync(4, 0, "Motor.Flags.3");
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection };
|
||||
var before = drv.GetHealth();
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||
factory.Tags["Motor.Flags"].Disposed.ShouldBeTrue();
|
||||
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead);
|
||||
}
|
||||
|
||||
/// <summary>M1 positive control: a refused parent read keeps the runtime, no health write.</summary>
|
||||
[Fact]
|
||||
public async Task A_refused_parent_read_keeps_the_runtime_and_health()
|
||||
{
|
||||
var (drv, factory) = await BuildAsync(4, 0, "Motor.Flags.3");
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.NotAllowed };
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||
factory.Tags["Motor.Flags"].Disposed.ShouldBeFalse();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 H1 / L4: a status that proves the controller ANSWERED (NotFound, OutOfBounds, NotAllowed,
|
||||
/// TooLarge, TooSmall, TypeMismatch, Unsupported) is a deterministic per-tag refusal, not a comms fault. It
|
||||
/// keeps the tag runtime (no evict → no lease / Forward-Open churn), writes no Degraded, opens no device-wide
|
||||
/// backoff window for siblings, warns once, and — since the device answered — advances LastSuccessfulRead.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipDeviceRefusalTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
/// <summary>A fake whose reads/writes throw a chosen transport status (init succeeds unless told otherwise).</summary>
|
||||
private sealed class RefusingTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
||||
{
|
||||
public AbStatus? ReadThrows { get; set; }
|
||||
public AbStatus? WriteThrows { get; set; }
|
||||
|
||||
public override async Task ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await base.ReadAsync(cancellationToken);
|
||||
if (ReadThrows is { } s) throw new AbTransportException(s, s.ToString());
|
||||
}
|
||||
|
||||
public override async Task WriteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await base.WriteAsync(cancellationToken);
|
||||
if (WriteThrows is { } s) throw new AbTransportException(s, s.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private static (AbCipDriver Driver, FakeAbCipTagFactory Factory) Build(
|
||||
Func<AbCipTagCreateParams, FakeAbCipTag> make)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory { Customise = make };
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = AbCipRawTags.From(
|
||||
new AbCipTagDefinition("Bad", Device, "Arr[999]", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("Good", Device, "Real1", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
MaxConcurrentReads = 1,
|
||||
}, "abcip-refusal", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
/// <summary>The classification itself: every answered refusal is reachable; transport failures are not.</summary>
|
||||
/// <param name="status">The transport status.</param>
|
||||
/// <param name="refusal">Whether it is a device refusal.</param>
|
||||
[Theory]
|
||||
[InlineData(AbStatus.NotFound, true)]
|
||||
[InlineData(AbStatus.OutOfBounds, true)]
|
||||
[InlineData(AbStatus.NotAllowed, true)]
|
||||
[InlineData(AbStatus.TooLarge, true)]
|
||||
[InlineData(AbStatus.TooSmall, true)]
|
||||
[InlineData(AbStatus.TypeMismatch, true)]
|
||||
[InlineData(AbStatus.Unsupported, true)]
|
||||
[InlineData(AbStatus.Ok, false)]
|
||||
[InlineData(AbStatus.Timeout, false)]
|
||||
[InlineData(AbStatus.BadConnection, false)]
|
||||
[InlineData(AbStatus.BadReply, false)]
|
||||
[InlineData(AbStatus.Partial, false)]
|
||||
[InlineData(AbStatus.Abort, false)]
|
||||
public void Answered_statuses_are_device_refusals(AbStatus status, bool refusal)
|
||||
{
|
||||
AbCipRefusals.IsDeviceRefusal(status).ShouldBe(refusal);
|
||||
AbCipDriverProbe.IsReachableStatus(status).ShouldBe(refusal || status == AbStatus.Ok);
|
||||
}
|
||||
|
||||
/// <summary>A read the controller refuses keeps its runtime, stays Healthy, and advances LastSuccessfulRead.</summary>
|
||||
/// <param name="status">The refusal the controller answers with.</param>
|
||||
/// <param name="expected">The OPC UA status the tag reads.</param>
|
||||
[Theory]
|
||||
[InlineData(AbStatus.NotFound, AbCipStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData(AbStatus.OutOfBounds, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData(AbStatus.TooLarge, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData(AbStatus.TooSmall, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData(AbStatus.NotAllowed, AbCipStatusMapper.BadNotReadable)]
|
||||
public async Task A_refused_read_keeps_the_runtime_and_writes_no_Degraded(AbStatus status, uint expected)
|
||||
{
|
||||
var (drv, factory) = Build(p => new RefusingTag(p) { ReadThrows = p.TagName == "Arr[999]" ? status : null, Value = 1 });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var before = drv.GetHealth().LastSuccessfulRead!.Value;
|
||||
await Task.Delay(20);
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var r = await drv.ReadAsync(["Bad"], CancellationToken.None);
|
||||
r[0].StatusCode.ShouldBe(expected);
|
||||
}
|
||||
|
||||
var bad = factory.Tags["Arr[999]"];
|
||||
bad.InitializeCount.ShouldBe(1); // never evicted → never re-created
|
||||
bad.Disposed.ShouldBeFalse();
|
||||
bad.ReadCount.ShouldBe(3);
|
||||
var health = drv.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Healthy);
|
||||
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before); // the device answered
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a genuine transport failure on the same tag still evicts and degrades.</summary>
|
||||
[Fact]
|
||||
public async Task A_transport_failure_still_evicts_and_degrades()
|
||||
{
|
||||
var (drv, factory) = Build(p => new RefusingTag(p) { ReadThrows = p.TagName == "Arr[999]" ? AbStatus.Timeout : null });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["Bad"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout);
|
||||
factory.Tags["Arr[999]"].Disposed.ShouldBeTrue();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
/// <summary>A refusal returned as a status word (no throw) is classified the same way.</summary>
|
||||
[Fact]
|
||||
public async Task A_refusal_status_word_keeps_the_runtime()
|
||||
{
|
||||
var (drv, factory) = Build(p => new FakeAbCipTag(p) { Status = p.TagName == "Arr[999]" ? (int)AbStatus.OutOfBounds : 0, Value = 1 });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["Bad"], CancellationToken.None);
|
||||
var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
|
||||
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
factory.Tags["Arr[999]"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["Arr[999]"].Disposed.ShouldBeFalse();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A cold create the controller refuses with OutOfBounds proves the session works: it must not open the
|
||||
/// device-wide backoff window, so a sibling cold tag still connects and reads Good.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_refused_create_does_not_throttle_sibling_cold_creates()
|
||||
{
|
||||
var (drv, _) = Build(p => p.TagName == "Arr[999]"
|
||||
? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = new AbTransportException(AbStatus.OutOfBounds, "OutOfBounds") }
|
||||
: new FakeAbCipTag(p) { Value = 42 });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
|
||||
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
r[1].Value.ShouldBe(42);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>A write the controller refuses keeps the runtime and writes no health.</summary>
|
||||
[Fact]
|
||||
public async Task A_refused_write_keeps_the_runtime_and_writes_no_Degraded()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new RefusingTag(p) { WriteThrows = AbStatus.NotAllowed } };
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Ro", Device, "ReadOnlyTag", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "abcip-refusal", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var w1 = await drv.WriteAsync([new WriteRequest("Ro", 5)], CancellationToken.None);
|
||||
var w2 = await drv.WriteAsync([new WriteRequest("Ro", 6)], CancellationToken.None);
|
||||
|
||||
w1[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||
w2[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||
factory.Tags["ReadOnlyTag"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["ReadOnlyTag"].Disposed.ShouldBeFalse();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// L4 end to end over the REAL native runtime + a private pool: a renamed tag (CIP 0x04) that is the device's
|
||||
/// ONLY lease holder used to be evicted every poll — the last release, so Forward Close, then a throttled Forward
|
||||
/// Open on the next poll. One runtime and one Forward Open across N polls and no Forward Close proves no churn.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_native_NotFound_tag_causes_no_runtime_or_connection_churn()
|
||||
{
|
||||
await using var target = new FakeCipTarget();
|
||||
target.ConnectedHandler = req => Task.FromResult<byte[]?>(
|
||||
req[0] == 0x52 ? [0xD2, 0x00, 0x04, 0x00] : target.ServiceReply(req));
|
||||
var host = $"ab://127.0.0.1:{target.Port}/1,0";
|
||||
var creates = 0;
|
||||
var native = new NativeAbCipTagFactory(new DeviceSessionPool());
|
||||
var factory = new CountingFactory(p => { Interlocked.Increment(ref creates); return native.Create(p); });
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Renamed", host, "Renamed", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
}, "abcip-l4-native", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
try
|
||||
{
|
||||
for (var n = 0; n < 5; n++)
|
||||
(await drv.ReadAsync(["Renamed"], CancellationToken.None))[0].StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
|
||||
Volatile.Read(ref creates).ShouldBe(1);
|
||||
target.ForwardOpens.ShouldBe(1);
|
||||
target.ForwardCloses.ShouldBe(0);
|
||||
target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(5); // positive control: every poll hit the wire
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CountingFactory(Func<AbCipTagCreateParams, IAbCipTagRuntime> create) : IAbCipTagFactory
|
||||
{
|
||||
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) => create(createParams);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #737 I2: a LOCAL refusal (libplctag ErrorNotImplemented — no I/O) is per-tag BadNotSupported, keeps health
|
||||
/// state, and does NOT advance LastSuccessfulRead (nothing proves the device answered). Positive control: the
|
||||
/// device's own Unsupported refusal does advance it (M2).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_local_refusal_does_not_advance_LastSuccessfulRead_but_a_device_one_does()
|
||||
{
|
||||
var (drv, _) = Build(p => p.TagName == "Arr[999]"
|
||||
? new LocalRefusingTag(p)
|
||||
: new RefusingTag(p) { ReadThrows = AbStatus.Unsupported });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var before = drv.GetHealth().LastSuccessfulRead!.Value;
|
||||
await Task.Delay(20);
|
||||
|
||||
(await drv.ReadAsync(["Bad"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldBe(before);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
(await drv.ReadAsync(["Good"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
|
||||
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before);
|
||||
}
|
||||
|
||||
/// <summary>#737 I2: a local refusal at create opens no backoff — a sibling cold create still connects.</summary>
|
||||
[Fact]
|
||||
public async Task A_local_refusal_at_create_does_not_throttle_or_count_as_answered()
|
||||
{
|
||||
var (drv, _) = Build(p => p.TagName == "Arr[999]"
|
||||
? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = new AbCipLocalRefusalException("ErrorNotImplemented") }
|
||||
: new FakeAbCipTag(p) { Value = 3 });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
|
||||
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>M2: a write the device refuses proves it answered — LastSuccessfulRead advances.</summary>
|
||||
[Fact]
|
||||
public async Task A_refused_write_advances_LastSuccessfulRead()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new RefusingTag(p) { WriteThrows = AbStatus.NotAllowed } };
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Ro", Device, "ReadOnlyTag", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "abcip-refusal", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var before = drv.GetHealth().LastSuccessfulRead!.Value;
|
||||
await Task.Delay(20);
|
||||
|
||||
(await drv.WriteAsync([new WriteRequest("Ro", 5)], CancellationToken.None))[0].StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||
|
||||
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
private sealed class LocalRefusingTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
||||
{
|
||||
public override async Task ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await base.ReadAsync(cancellationToken);
|
||||
throw new AbCipLocalRefusalException("ErrorNotImplemented");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle overlap the runtime can produce (review 07 H13/F10, Lane A): the final stop runs
|
||||
/// <c>ShutdownAsync</c> then <c>DisposeAsync</c> under one bounded budget, so a slow shutdown is abandoned and
|
||||
/// the dispose runs CONCURRENTLY with it; an abandoned <c>InitializeAsync</c> can overlap the next attempt's
|
||||
/// shutdown. Device tables are build-and-swapped, so each teardown owns a disjoint set of device states.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipLifecycleConcurrencyTests
|
||||
{
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
|
||||
/// <summary>A fake whose reads ignore cancellation for a while, so a teardown waits on the probe loop.</summary>
|
||||
private sealed class SlowTag(AbCipTagCreateParams p, TimeSpan readDelay) : FakeAbCipTag(p)
|
||||
{
|
||||
private int _disposeCount;
|
||||
|
||||
public int DisposeCount => Volatile.Read(ref _disposeCount);
|
||||
|
||||
public override async Task ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await base.ReadAsync(cancellationToken);
|
||||
if (readDelay > TimeSpan.Zero) await Task.Delay(readDelay, CancellationToken.None);
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
Interlocked.Increment(ref _disposeCount);
|
||||
base.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static (AbCipDriver Driver, FakeAbCipTagFactory Factory) Build(TimeSpan probeReadDelay)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new SlowTag(p, p.TagName == "ProbeTag" ? probeReadDelay : TimeSpan.Zero) { Value = 1 },
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Host)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Host, "Motor1.Speed", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
ProbeTagPath = "ProbeTag",
|
||||
Interval = TimeSpan.FromMilliseconds(10),
|
||||
Timeout = TimeSpan.FromMilliseconds(200),
|
||||
},
|
||||
}, "abcip-lifecycle", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, string what, int seconds = 5)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(seconds);
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow > deadline) throw new TimeoutException(what);
|
||||
await Task.Delay(5);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shutdown and Dispose racing each other tear the device down exactly once: the data runtime is
|
||||
/// disposed once (not once per teardown), neither call throws, and the probe loop has exited.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Dispose_concurrent_with_a_running_Shutdown_tears_each_state_down_exactly_once()
|
||||
{
|
||||
var (drv, factory) = Build(probeReadDelay: TimeSpan.FromMilliseconds(300));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await drv.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
|
||||
var state = drv.GetDeviceState(Host)!;
|
||||
var probeLoop = state.ProbeTask!;
|
||||
var data = (SlowTag)factory.Tags["Motor1.Speed"];
|
||||
|
||||
var shutdown = drv.ShutdownAsync(CancellationToken.None);
|
||||
var dispose = drv.DisposeAsync().AsTask();
|
||||
await Task.WhenAll(shutdown, dispose);
|
||||
|
||||
data.DisposeCount.ShouldBe(1);
|
||||
probeLoop.IsCompleted.ShouldBeTrue();
|
||||
drv.GetDeviceState(Host).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An InitializeAsync that a ShutdownAsync overtakes publishes nothing: no device state and no probe
|
||||
/// loop survive the shutdown. Positive control: an init that nothing overtakes still publishes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_init_overtaken_by_Shutdown_publishes_no_device_and_starts_no_probe()
|
||||
{
|
||||
var (drv, factory) = Build(probeReadDelay: TimeSpan.FromMilliseconds(300));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
|
||||
var firstProbe = factory.Tags["ProbeTag"];
|
||||
|
||||
// The second init's own release waits on the slow first probe loop; the shutdown overtakes it there.
|
||||
var init = drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await Task.Delay(50);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
var superseded = await Should.ThrowAsync<AbCipInitializationSupersededException>(() => init);
|
||||
superseded.ShouldNotBeAssignableTo<OperationCanceledException>(); // M4: distinct from a caller cancellation
|
||||
|
||||
drv.GetDeviceState(Host).ShouldBeNull();
|
||||
drv.GetHostStatuses().ShouldBeEmpty();
|
||||
await Task.Delay(100);
|
||||
factory.Tags["ProbeTag"].ShouldBeSameAs(firstProbe); // no second probe loop ever created a runtime
|
||||
drv.GetHealth().State.ShouldNotBe(DriverState.Healthy);
|
||||
|
||||
// Positive control: a later init that nothing overtakes publishes the device again.
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
drv.GetDeviceState(Host).ShouldNotBeNull();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
await drv.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Readers of the device/routing tables (the retained poll engine, the actor's host-status pull) never
|
||||
/// see a table mid-mutation while the driver re-initialises underneath them, never trip over a per-tag lock a
|
||||
/// retire disposed, and never leak a runtime they cold-created into a device state a retire already tore down.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Readers_survive_repeated_reinitialisation_and_leak_no_runtime()
|
||||
{
|
||||
var created = new System.Collections.Concurrent.ConcurrentBag<FakeAbCipTag>();
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p =>
|
||||
{
|
||||
var t = new FakeAbCipTag(p) { Value = 1 };
|
||||
created.Add(t);
|
||||
return t;
|
||||
},
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Host)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Host, "Motor1.Speed", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "abcip-lifecycle", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
using var stop = new CancellationTokenSource();
|
||||
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var badHealth = 0;
|
||||
var reader = Task.Run(async () =>
|
||||
{
|
||||
var reads = 0;
|
||||
while (!stop.IsCancellationRequested)
|
||||
{
|
||||
_ = drv.GetHostStatuses();
|
||||
_ = drv.ResolveHost("Speed");
|
||||
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
if (drv.GetHealth().State == DriverState.Degraded) Interlocked.Increment(ref badHealth);
|
||||
reads++;
|
||||
started.TrySetResult();
|
||||
}
|
||||
return reads;
|
||||
});
|
||||
await started.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
for (var i = 0; i < 200; i++)
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
stop.Cancel();
|
||||
|
||||
(await reader).ShouldBeGreaterThan(0); // positive control: the reader actually ran
|
||||
Volatile.Read(ref badHealth).ShouldBe(0, "a re-init is not a comms fault");
|
||||
await drv.DisposeAsync();
|
||||
created.Where(t => !t.Disposed).ShouldBeEmpty("every runtime a reader created must be disposed by some teardown");
|
||||
}
|
||||
|
||||
/// <summary>F10: DisposeAsync is the complete, idempotent teardown (the runtime prefers it).</summary>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_is_a_complete_idempotent_teardown()
|
||||
{
|
||||
var (drv, factory) = Build(probeReadDelay: TimeSpan.Zero);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
|
||||
var loop = drv.GetDeviceState(Host)!.ProbeTask!;
|
||||
|
||||
await drv.DisposeAsync();
|
||||
await drv.DisposeAsync(); // idempotent
|
||||
|
||||
loop.IsCompleted.ShouldBeTrue();
|
||||
factory.Tags["ProbeTag"].Disposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F10: the sync <c>Dispose()</c> no longer blocks the caller's thread on the async teardown (sync-over-async —
|
||||
/// a dispatcher thread for up to the probe join). It starts the same teardown and returns; the teardown still
|
||||
/// completes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Sync_Dispose_does_not_block_on_the_teardown_but_still_completes_it()
|
||||
{
|
||||
var (drv, factory) = Build(probeReadDelay: TimeSpan.FromSeconds(4));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
|
||||
var loop = drv.GetDeviceState(Host)!.ProbeTask!;
|
||||
await Task.Delay(50); // the probe is inside its slow, cancellation-deaf read
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
((IDisposable)drv).Dispose();
|
||||
sw.Stop();
|
||||
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2)); // did not wait out the 4 s probe read (CI-tolerant margin)
|
||||
await WaitUntilAsync(() => loop.IsCompleted && factory.Tags["ProbeTag"].Disposed, "teardown never completed", seconds: 15);
|
||||
drv.GetDeviceState(Host).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M4: a ShutdownAsync overtaken by a NEWER InitializeAsync (its probe join is slow) must not stamp Unknown over
|
||||
/// the newer init's Healthy when it finally finishes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_slow_shutdown_does_not_overwrite_a_newer_inits_health()
|
||||
{
|
||||
var (drv, factory) = Build(probeReadDelay: TimeSpan.FromMilliseconds(400));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
|
||||
await Task.Delay(30); // inside the slow probe read
|
||||
|
||||
var shutdown = drv.ShutdownAsync(CancellationToken.None);
|
||||
await Task.Delay(30);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None); // newer: publishes while the shutdown still joins
|
||||
await shutdown;
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
drv.GetDeviceState(Host).ShouldNotBeNull();
|
||||
await drv.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 22 review r1: a runtime that refuses an operation with <see cref="AbStatus.Unsupported"/> (the native
|
||||
/// transport does this before the wire, e.g. a write it cannot type) reaches the client as BadNotSupported —
|
||||
/// transport does this before the wire, e.g. a write it cannot type — an <see cref="AbCipLocalRefusalException"/>
|
||||
/// since #737 I2) reaches the client as BadNotSupported —
|
||||
/// NOT as a comms fault. The handle is kept (one create, never disposed) and driver health is untouched.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
@@ -19,12 +20,12 @@ public sealed class AbCipLocalRefusalTests
|
||||
{
|
||||
public override Task ReadAsync(CancellationToken cancellationToken) =>
|
||||
refuseRead
|
||||
? Task.FromException(new AbTransportException(AbStatus.Unsupported, "refused read"))
|
||||
? Task.FromException(new AbCipLocalRefusalException("refused read"))
|
||||
: base.ReadAsync(cancellationToken);
|
||||
|
||||
public override Task WriteAsync(CancellationToken cancellationToken) =>
|
||||
refuseWrite
|
||||
? Task.FromException(new AbTransportException(AbStatus.Unsupported, "refused write"))
|
||||
? Task.FromException(new AbCipLocalRefusalException("refused write"))
|
||||
: base.WriteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using CapturingLogger = ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.AbCipLoggingTests.CapturingLogger<ZB.MOM.WW.OtOpcUa.Driver.AbCip.AbCipDriver>;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 H10: a typo'd (or download-deleted) <c>Probe.ProbeTagPath</c> used to record a Forward-Open
|
||||
/// FAILURE every probe tick, so the device's backoff window never closed and every cold / evicted data tag read
|
||||
/// <c>BadCommunicationError</c> forever — a permanent device blackout that a restart did not cure. The controller
|
||||
/// ANSWERED, so the probe now counts it as reachable: backoff success, host Running, one config Warning.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipProbeRefusalTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
private static (AbCipDriver Driver, CapturingLogger Logger) Build(Exception probeFault)
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => p.TagName == "ProbeTagTypo"
|
||||
? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = probeFault }
|
||||
: new FakeAbCipTag(p) { Value = 7 },
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Motor1.Speed", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
ProbeTagPath = "ProbeTagTypo",
|
||||
Interval = TimeSpan.FromMilliseconds(10),
|
||||
Timeout = TimeSpan.FromMilliseconds(100),
|
||||
},
|
||||
}, "abcip-probe-refusal", factory, logger: logger);
|
||||
return (drv, logger);
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition never met");
|
||||
await Task.Delay(5);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A NotFound probe tag leaves the device reachable: data tags still connect, host is Running, warned once.</summary>
|
||||
[Fact]
|
||||
public async Task A_missing_probe_tag_does_not_black_out_the_device()
|
||||
{
|
||||
var (drv, logger) = Build(new AbTransportException(AbStatus.NotFound, "NotFound"));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitUntilAsync(() => drv.GetHostStatuses().Single().State == HostState.Running);
|
||||
await Task.Delay(100); // many probe ticks
|
||||
|
||||
var r = await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); // a cold create, NOT throttled
|
||||
r[0].Value.ShouldBe(7);
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
|
||||
logger.Entries.Count(e => e.Level == LogLevel.Warning && e.Message.Contains("ProbeTagTypo")).ShouldBe(1);
|
||||
await drv.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a probe that genuinely cannot connect still stops the host and throttles cold creates.</summary>
|
||||
[Fact]
|
||||
public async Task An_unreachable_device_still_stops_the_host_and_throttles()
|
||||
{
|
||||
var (drv, _) = Build(new AbTransportException(AbStatus.Timeout, "Timeout"));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitUntilAsync(() => drv.GetHostStatuses().Single().State == HostState.Stopped);
|
||||
|
||||
var r = await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError); // throttled by the open window
|
||||
await drv.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 P8 / P7: the bounded-parallel read window. On the native transport every concurrent tag read of a
|
||||
/// device rides ONE shared request scheduler that packs whatever is queued into a Multiple Service Packet, so a
|
||||
/// window of 8 capped MSP coalescing at 8 services per round trip; it is widened to at least 64 there (the
|
||||
/// scheduler's reply budget already prevents over-packing). And a cold create waiting on a device's connect gate
|
||||
/// must not occupy a window slot, or one dead device's backoff-expiry attempt stalls every other device's reads.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipReadWindowTests
|
||||
{
|
||||
private static AbCipDriverOptions Options(int maxConcurrentReads, AbCipTransport? transport = null) => new()
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
MaxConcurrentReads = maxConcurrentReads,
|
||||
Transport = transport,
|
||||
};
|
||||
|
||||
/// <summary>The window per transport: native ≥ 64; libplctag and injected factories honour the option exactly.</summary>
|
||||
[Fact]
|
||||
public void The_native_window_is_at_least_64_and_other_transports_keep_the_option()
|
||||
{
|
||||
new AbCipDriver(Options(8, AbCipTransport.Native), "a").ReadWindow.ShouldBe(AbCipDriver.NativeMinReadWindow);
|
||||
AbCipDriver.NativeMinReadWindow.ShouldBeGreaterThanOrEqualTo(64);
|
||||
new AbCipDriver(Options(1, AbCipTransport.Native), "a").ReadWindow.ShouldBe(AbCipDriver.NativeMinReadWindow);
|
||||
new AbCipDriver(Options(200, AbCipTransport.Native), "a").ReadWindow.ShouldBe(200);
|
||||
new AbCipDriver(Options(8), "a", defaultTransport: AbCipTransport.Native).ReadWindow.ShouldBe(AbCipDriver.NativeMinReadWindow);
|
||||
|
||||
// Positive controls: libplctag (per-handle concurrency) and an injected test factory keep the option.
|
||||
new AbCipDriver(Options(8, AbCipTransport.Libplctag), "a").ReadWindow.ShouldBe(8);
|
||||
new AbCipDriver(Options(0, AbCipTransport.Libplctag), "a").ReadWindow.ShouldBe(1);
|
||||
new AbCipDriver(Options(3, AbCipTransport.Native), "a", new FakeAbCipTagFactory()).ReadWindow.ShouldBe(3);
|
||||
}
|
||||
|
||||
/// <summary>A tag whose initialisation blocks until released (a cold connect to a slow / dead device).</summary>
|
||||
private sealed class BlockingInitTag(AbCipTagCreateParams p, Task release) : FakeAbCipTag(p)
|
||||
{
|
||||
public override async Task InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await base.InitializeAsync(cancellationToken);
|
||||
await release.WaitAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With a window of ONE, device B's blocked cold create (and its queued sibling) must not stop device A's tag
|
||||
/// from being read: cold waiters sit on B's connect gate, not in the window.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_blocked_cold_create_on_one_device_does_not_hold_the_window_against_another()
|
||||
{
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => p.Gateway == "10.0.0.6"
|
||||
? new BlockingInitTag(p, release.Task) { Value = 2 }
|
||||
: new FakeAbCipTag(p) { Value = 1 },
|
||||
};
|
||||
const string a = "ab://10.0.0.5/1,0", b = "ab://10.0.0.6/1,0";
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(a), new AbCipDeviceOptions(b)],
|
||||
RawTags = AbCipRawTags.From(
|
||||
new AbCipTagDefinition("B1", b, "B1", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("B2", b, "B2", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("A1", a, "A1", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
MaxConcurrentReads = 1,
|
||||
}, "abcip-window", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var read = drv.ReadAsync(["B1", "B2", "A1"], CancellationToken.None);
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (!(factory.Tags.TryGetValue("A1", out var tag) && tag.ReadCount > 0))
|
||||
{
|
||||
if (DateTime.UtcNow > deadline)
|
||||
{
|
||||
release.TrySetResult();
|
||||
await read;
|
||||
throw new ShouldAssertException("device A's read waited behind device B's blocked cold create");
|
||||
}
|
||||
await Task.Delay(5);
|
||||
}
|
||||
read.IsCompleted.ShouldBeFalse(); // B is still blocked — A was read regardless
|
||||
|
||||
release.SetResult();
|
||||
var r = await read;
|
||||
r.Select(x => x.Value).ShouldBe([2, 2, 1]); // positive control: B's reads complete once it connects
|
||||
await drv.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ public sealed class AbCipStatusMapperTests
|
||||
Status.Ok => AbCipStatusMapper.Good,
|
||||
Status.Pending => AbCipStatusMapper.GoodMoreData,
|
||||
Status.ErrorTimeout => AbCipStatusMapper.BadTimeout,
|
||||
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbCipStatusMapper.BadNodeIdUnknown,
|
||||
Status.ErrorNotFound => AbCipStatusMapper.BadNodeIdUnknown, // #737 I2: NoMatch/BadDevice are comms
|
||||
Status.ErrorNotAllowed => AbCipStatusMapper.BadNotWritable,
|
||||
Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => AbCipStatusMapper.BadOutOfRange,
|
||||
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbCipStatusMapper.BadNotSupported,
|
||||
|
||||
@@ -73,7 +73,7 @@ public sealed class AbCipTypeMismatchTests
|
||||
creates().ShouldBe(1);
|
||||
factory.Tags["T"].Disposed.ShouldBeFalse();
|
||||
factory.Tags["T"].ReadCount.ShouldBe(Polls); // positive control: every poll did reach the runtime
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
AssertStateUnchanged(drv.GetHealth(), health);
|
||||
MismatchWarnings(log).ShouldBe(1);
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ public sealed class AbCipTypeMismatchTests
|
||||
Volatile.Read(ref creates).ShouldBe(1);
|
||||
factory.Tags["T"].Disposed.ShouldBeFalse();
|
||||
factory.Tags["T"].ReadCount.ShouldBe(Polls); // positive control: every poll did reach the runtime
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
AssertStateUnchanged(drv.GetHealth(), health);
|
||||
drv.GetHealth().State.ShouldNotBe(DriverState.Degraded);
|
||||
MismatchWarnings(log).ShouldBe(1);
|
||||
}
|
||||
@@ -121,7 +121,7 @@ public sealed class AbCipTypeMismatchTests
|
||||
|
||||
creates().ShouldBe(1);
|
||||
factory.Tags["T"].Disposed.ShouldBeFalse();
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
AssertStateUnchanged(drv.GetHealth(), health);
|
||||
MismatchWarnings(log).ShouldBe(1);
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ public sealed class AbCipTypeMismatchTests
|
||||
target.ForwardOpens.ShouldBe(1);
|
||||
target.ForwardCloses.ShouldBe(0);
|
||||
target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(Polls); // positive control: polls hit the wire
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
AssertStateUnchanged(drv.GetHealth(), health);
|
||||
MismatchWarnings(log).ShouldBe(1);
|
||||
}
|
||||
finally
|
||||
@@ -226,7 +226,7 @@ public sealed class AbCipTypeMismatchTests
|
||||
target.ForwardOpens.ShouldBe(1);
|
||||
target.ForwardCloses.ShouldBe(0);
|
||||
target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(Polls); // positive control: polls hit the wire
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
AssertStateUnchanged(drv.GetHealth(), health);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -267,12 +267,23 @@ public sealed class AbCipTypeMismatchTests
|
||||
Volatile.Read(ref creates).ShouldBe(1);
|
||||
factory.Tags["T"].Disposed.ShouldBeFalse();
|
||||
factory.Tags["T"].ReadCount.ShouldBe(Polls - 1); // positive control: later polls reached the kept runtime
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
AssertStateUnchanged(drv.GetHealth(), health);
|
||||
}
|
||||
|
||||
private sealed class CountingFactory(Func<AbCipTagCreateParams, IAbCipTagRuntime> create) : IAbCipTagFactory
|
||||
{
|
||||
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) => create(createParams);
|
||||
}
|
||||
/// <summary>
|
||||
/// A refusal leaves the health STATE and error alone; since the controller answered, LastSuccessfulRead may
|
||||
/// only move forward (review 07 H1 — any data proving the device answered advances it).
|
||||
/// </summary>
|
||||
private static void AssertStateUnchanged(DriverHealth actual, DriverHealth before)
|
||||
{
|
||||
actual.State.ShouldBe(before.State);
|
||||
actual.LastError.ShouldBe(before.LastError);
|
||||
(actual.LastSuccessfulRead ?? DateTime.MinValue).ShouldBeGreaterThanOrEqualTo(before.LastSuccessfulRead ?? DateTime.MinValue);
|
||||
}
|
||||
|
||||
#pragma warning restore OTOPCUA0001
|
||||
}
|
||||
|
||||
@@ -99,4 +99,26 @@ public sealed class AbCipUdtMemberLayoutTests
|
||||
new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4),
|
||||
}).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Review 07 P11: the layout is computed once per member list, not once per member per poll.</summary>
|
||||
[Fact]
|
||||
public void GetOrBuild_computes_a_layout_once_per_member_list()
|
||||
{
|
||||
IReadOnlyList<AbCipStructureMember> members =
|
||||
[
|
||||
new AbCipStructureMember("A", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("B", AbCipDataType.Real),
|
||||
];
|
||||
var first = AbCipUdtMemberLayout.GetOrBuild(members);
|
||||
first.ShouldNotBeNull();
|
||||
first["B"].ShouldBe(4);
|
||||
AbCipUdtMemberLayout.GetOrBuild(members).ShouldBeSameAs(first);
|
||||
|
||||
// Positive control: a different (equal-content) list is its own entry, and a not-groupable verdict caches too.
|
||||
IReadOnlyList<AbCipStructureMember> copy = [.. members];
|
||||
AbCipUdtMemberLayout.GetOrBuild(copy).ShouldNotBeSameAs(first);
|
||||
IReadOnlyList<AbCipStructureMember> withBool = [new AbCipStructureMember("F", AbCipDataType.Bool)];
|
||||
AbCipUdtMemberLayout.GetOrBuild(withBool).ShouldBeNull();
|
||||
AbCipUdtMemberLayout.GetOrBuild(withBool).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 H19: write-side health. A client value that cannot be converted never touched the wire, so it must
|
||||
/// not latch <c>Degraded</c>; and a good write must not overwrite a <c>Degraded</c> the read path just recorded
|
||||
/// with <c>Healthy</c> (the batch read path is worst-wins for exactly this reason). A good write proves the device
|
||||
/// answered, so it advances <c>LastSuccessfulRead</c> without touching the state.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipWriteHealthTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
private sealed class ThrowingWriteTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
||||
{
|
||||
public override Task WriteAsync(CancellationToken cancellationToken) =>
|
||||
throw new InvalidOperationException("socket reset");
|
||||
}
|
||||
|
||||
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)> BuildAsync(
|
||||
Func<AbCipTagCreateParams, FakeAbCipTag>? make = null)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = make ?? (p => p.TagName == "Broken"
|
||||
? new FakeAbCipTag(p) { ThrowOnRead = true }
|
||||
: new RealEncodingTag(p) { Value = 0 }),
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = AbCipRawTags.From(
|
||||
new AbCipTagDefinition("D", Device, "D", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("I", Device, "I", AbCipDataType.Int),
|
||||
new AbCipTagDefinition("Broken", Device, "Broken", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "abcip-write-health", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
/// <summary>A fake whose encode converts like the real runtimes do (Convert.ToInt32 / ToInt16).</summary>
|
||||
private sealed class RealEncodingTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
||||
{
|
||||
public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value) =>
|
||||
Value = type switch
|
||||
{
|
||||
AbCipDataType.Int => Convert.ToInt16(value),
|
||||
_ => Convert.ToInt32(value),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Unconvertible / out-of-range client values: refused per tag, health untouched, nothing sent.</summary>
|
||||
[Fact]
|
||||
public async Task A_client_conversion_error_writes_no_health()
|
||||
{
|
||||
var (drv, factory) = await BuildAsync();
|
||||
var before = drv.GetHealth();
|
||||
|
||||
var r = await drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("D", "not a number"), // FormatException
|
||||
new WriteRequest("D", new object()), // InvalidCastException
|
||||
new WriteRequest("I", 1_000_000), // OverflowException
|
||||
], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
|
||||
r[1].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
|
||||
r[2].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
|
||||
drv.GetHealth().ShouldBe(before);
|
||||
factory.Tags["D"].WriteCount.ShouldBe(0);
|
||||
factory.Tags["I"].WriteCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>A good write keeps a Degraded the read path recorded, and advances LastSuccessfulRead.</summary>
|
||||
[Fact]
|
||||
public async Task A_good_write_does_not_overwrite_Degraded_with_Healthy()
|
||||
{
|
||||
var (drv, _) = await BuildAsync();
|
||||
await drv.ReadAsync(["Broken"], CancellationToken.None);
|
||||
var degraded = drv.GetHealth();
|
||||
degraded.State.ShouldBe(DriverState.Degraded);
|
||||
await Task.Delay(20);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
var after = drv.GetHealth();
|
||||
after.State.ShouldBe(DriverState.Degraded);
|
||||
after.LastError.ShouldBe(degraded.LastError);
|
||||
after.LastSuccessfulRead!.Value.ShouldBeGreaterThan(degraded.LastSuccessfulRead!.Value); // the device answered
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a genuine write transport fault still degrades and preserves LastSuccessfulRead.</summary>
|
||||
[Fact]
|
||||
public async Task A_write_transport_fault_still_degrades()
|
||||
{
|
||||
var (drv, factory) = await BuildAsync(p => new ThrowingWriteTag(p));
|
||||
var before = drv.GetHealth();
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||
var after = drv.GetHealth();
|
||||
after.State.ShouldBe(DriverState.Degraded);
|
||||
after.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead);
|
||||
factory.Tags["D"].Disposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>M3: a good write clears a Degraded that a WRITE comms fault recorded.</summary>
|
||||
[Fact]
|
||||
public async Task A_good_write_clears_a_write_side_Degraded()
|
||||
{
|
||||
var fail = true;
|
||||
var (drv, _) = await BuildAsync(p => fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p));
|
||||
(await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None))[0].StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
fail = false; // the evicted runtime is re-created healthy
|
||||
(await drv.WriteAsync([new WriteRequest("D", 6)], CancellationToken.None))[0].StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.Good);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
drv.GetHealth().LastError.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// m1: many concurrent failing writes leave the write-owned marker naming the installed Degraded, so one good
|
||||
/// write still clears it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Concurrent_failing_writes_then_a_good_write_clear_the_write_side_Degraded()
|
||||
{
|
||||
var fail = true;
|
||||
var (drv, _) = await BuildAsync(p => fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p));
|
||||
for (var round = 0; round < 20; round++)
|
||||
{
|
||||
fail = true;
|
||||
await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Task.Run(() =>
|
||||
drv.WriteAsync([new WriteRequest("D", 1), new WriteRequest("I", 2)], CancellationToken.None))));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
fail = false;
|
||||
(await drv.WriteAsync([new WriteRequest("D", 3)], CancellationToken.None))[0].StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.Good);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy, $"round {round}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M3: a refusal-only read batch in between (no read comms fault happened) keeps the Degraded write-owned, so a
|
||||
/// good write still clears it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_refusal_only_read_between_keeps_the_Degraded_write_owned()
|
||||
{
|
||||
var fail = true;
|
||||
var (drv, _) = await BuildAsync(p => p.TagName == "Broken"
|
||||
? new FakeAbCipTag(p) { Status = (int)ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbStatus.NotFound }
|
||||
: fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p));
|
||||
await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
(await drv.ReadAsync(["Broken"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
|
||||
fail = false;
|
||||
await drv.WriteAsync([new WriteRequest("D", 6)], CancellationToken.None);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,25 @@ internal class FakeAbCipTag : IAbCipTagRuntime
|
||||
/// <param name="value">The value to encode.</param>
|
||||
public virtual void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value;
|
||||
|
||||
/// <summary>The element type of the last <see cref="EncodeArray"/>, if any.</summary>
|
||||
public AbCipDataType? EncodedArrayType { get; private set; }
|
||||
|
||||
/// <summary>The bytes the last <see cref="EncodeArray"/> produced (the shared codec, so conversions throw as in production).</summary>
|
||||
public byte[]? EncodedArrayBytes { get; private set; }
|
||||
|
||||
/// <summary>The element count of the last <see cref="EncodeArray"/>.</summary>
|
||||
public int EncodedArrayCount { get; private set; }
|
||||
|
||||
/// <summary>Encodes an array write through the production codec and records it.</summary>
|
||||
/// <param name="type">The element type.</param>
|
||||
/// <param name="values">The element values.</param>
|
||||
public virtual void EncodeArray(AbCipDataType type, IReadOnlyList<object?> values)
|
||||
{
|
||||
EncodedArrayBytes = AbCipArrayCodec.Encode(type, values);
|
||||
EncodedArrayType = type;
|
||||
EncodedArrayCount = values.Count;
|
||||
}
|
||||
|
||||
/// <summary>Marks the tag as disposed.</summary>
|
||||
public virtual void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
+22
-2
@@ -16,8 +16,8 @@ public sealed class LibplctagStatusTranslationTests
|
||||
[InlineData(Status.Pending, AbStatus.Pending)]
|
||||
[InlineData(Status.ErrorTimeout, AbStatus.Timeout)]
|
||||
[InlineData(Status.ErrorNotFound, AbStatus.NotFound)]
|
||||
[InlineData(Status.ErrorNoMatch, AbStatus.NotFound)]
|
||||
[InlineData(Status.ErrorBadDevice, AbStatus.NotFound)]
|
||||
[InlineData(Status.ErrorNoMatch, AbStatus.BadConnection)] // #737 I2: not proof the controller answered
|
||||
[InlineData(Status.ErrorBadDevice, AbStatus.BadConnection)]
|
||||
[InlineData(Status.ErrorNotAllowed, AbStatus.NotAllowed)]
|
||||
[InlineData(Status.ErrorOutOfBounds, AbStatus.OutOfBounds)]
|
||||
[InlineData(Status.ErrorTooLarge, AbStatus.TooLarge)]
|
||||
@@ -148,4 +148,24 @@ public sealed class LibplctagStatusTranslationTests
|
||||
LibplctagStatusTranslation.Wrap(() => throw new LibPlcTagException(Status.ErrorBadParam)));
|
||||
thrown.Status.ShouldBe(AbStatus.BadConnection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #737 I2: libplctag's <c>ErrorNotImplemented</c> is the LIBRARY refusing (no I/O): a local refusal, not a device
|
||||
/// refusal — it must not count as "the controller answered".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task NotImplemented_is_a_local_refusal_not_a_device_refusal()
|
||||
{
|
||||
var ex = await Should.ThrowAsync<AbTransportException>(() =>
|
||||
LibplctagStatusTranslation.Wrap(() => throw new LibPlcTagException("ErrorNotImplemented")));
|
||||
ex.ShouldBeOfType<AbCipLocalRefusalException>();
|
||||
ex.Status.ShouldBe(AbStatus.Unsupported);
|
||||
AbCipRefusals.IsDeviceRefusal(ex).ShouldBeFalse();
|
||||
AbCipDriverProbe.IsReachableException(ex).ShouldBeFalse();
|
||||
|
||||
// Positive control: the device's own Unsupported refusal still is one.
|
||||
var device = await Should.ThrowAsync<AbTransportException>(() =>
|
||||
LibplctagStatusTranslation.Wrap(() => throw new LibPlcTagException("ErrorUnsupported")));
|
||||
AbCipRefusals.IsDeviceRefusal(device).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,163 @@ public sealed class NativeAbCipTagRuntimeTests
|
||||
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U11: a scalar BOOL authored over a BOOL-array element <c>Bits[n]</c> reads DWORD <c>n / 32</c>
|
||||
/// (a Logix BOOL array is stored as DWORDs and its CIP element index addresses them) and decodes bit
|
||||
/// <c>n % 32</c> of it — it used to request element n and report "any of bits 0-7 of the reply set".
|
||||
/// </summary>
|
||||
/// <param name="n">The authored BOOL-array element.</param>
|
||||
/// <param name="dword">The DWORD the request must address.</param>
|
||||
/// <param name="reply">The DWORD the controller returns (little-endian hex).</param>
|
||||
/// <param name="expected">The element's value.</param>
|
||||
[Theory]
|
||||
[InlineData(37, 1, "20000000", true)] // bit 5 of DWORD 1
|
||||
[InlineData(36, 1, "20000000", false)] // bit 4 of the same DWORD — the old "low byte != 0" read true
|
||||
[InlineData(5, 0, "20000000", true)]
|
||||
[InlineData(1, 0, "01000000", false)] // bit 1, not bit 0
|
||||
[InlineData(31, 0, "00000080", true)] // top bit of DWORD 0 — outside the low byte entirely
|
||||
public async Task A_BOOL_array_element_decodes_its_bit_of_its_DWORD(int n, int dword, string reply, bool expected)
|
||||
{
|
||||
var ch = Replying("D300", reply);
|
||||
using var rt = await ReadAsync(ch, Params($"Bits[{n}]", AbCipDataType.Bool));
|
||||
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(expected);
|
||||
ch.Sent[0].ShouldBe(Convert.FromHexString("5204" + "910442697473" + "28" + dword.ToString("X2") + "0100" + "00000000"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #737 I1: a family whose BOOL array is element-addressed (the controller answers the DWORD-index request with a
|
||||
/// plain BOOL, 0xC1) falls back to the ORIGINAL element path <c>Bits[n]</c> — it must neither decode nor write
|
||||
/// element <c>n / 32</c>. Positive control: the 0xD3 theory above still decodes bit <c>n % 32</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_per_element_BOOL_array_falls_back_to_the_element_path_for_read_and_write()
|
||||
{
|
||||
var ch = new FakeCipChannel
|
||||
{
|
||||
Respond = req => Task.FromResult(Convert.FromHexString(req[0] == 0x4D ? "CD000000" : "D2000000C10001")),
|
||||
};
|
||||
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool));
|
||||
await rt.InitializeAsync(CancellationToken.None);
|
||||
await rt.ReadAsync(CancellationToken.None);
|
||||
|
||||
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
|
||||
ch.Sent[^1].ShouldBe(Convert.FromHexString("5204" + "910442697473" + "2825" + "0100" + "00000000")); // Bits[37]
|
||||
|
||||
rt.EncodeValue(AbCipDataType.Bool, null, false);
|
||||
await rt.WriteAsync(CancellationToken.None);
|
||||
ch.Sent[^1].ShouldBe(Convert.FromHexString("4D04" + "910442697473" + "2825" + "C100" + "0100" + "00")); // Bits[37]
|
||||
}
|
||||
|
||||
/// <summary>A channel whose reply can be switched mid-test (read replies; writes always succeed).</summary>
|
||||
private static FakeCipChannel Switchable(Func<string> readReply) => new()
|
||||
{
|
||||
Respond = req => Task.FromResult(Convert.FromHexString(req[0] == 0x4D ? "CD000000" : readReply())),
|
||||
};
|
||||
|
||||
private const string Dword1 = "5204" + "910442697473" + "2801" + "0100" + "00000000"; // Bits[1] (DWORD 37/32)
|
||||
private const string Element37 = "5204" + "910442697473" + "2825" + "0100" + "00000000"; // Bits[37] (element)
|
||||
|
||||
/// <summary>
|
||||
/// #737 fix round 2 (N1): a REFUSAL of the DWORD request says nothing about the layout — it must not settle it.
|
||||
/// Tag deployed before the online edit that creates <c>Bits : BOOL[2048]</c>: the init read is refused NotFound;
|
||||
/// once the array exists the controller answers 0xD3, and the read must address DWORD n/32 and decode bit n%32
|
||||
/// (the round-1 code had permanently fallen back to <c>Bits[37]</c> = DWORD 37 and decoded byte 0 as Good).
|
||||
/// </summary>
|
||||
/// <param name="refusal">The refusal reply the DWORD request first gets.</param>
|
||||
[Theory]
|
||||
[InlineData("D2000400")] // NotFound
|
||||
[InlineData("D2000F00")] // NotAllowed
|
||||
[InlineData("D2000A00")] // OutOfBounds
|
||||
public async Task A_refusal_leaves_the_BOOL_layout_unsettled_and_a_later_DWORD_reply_decodes_the_right_bit(string refusal)
|
||||
{
|
||||
var reply = refusal;
|
||||
var ch = Switchable(() => reply);
|
||||
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool));
|
||||
await rt.InitializeAsync(CancellationToken.None); // refused: completes with the refusal status
|
||||
rt.GetStatus().ShouldNotBe((int)AbStatus.Ok);
|
||||
ch.Sent[^1].ShouldBe(Convert.FromHexString(Dword1)); // never fell back to the element path
|
||||
|
||||
reply = "D2000000D300" + "20000000"; // bit 5 of DWORD 1 = element 37
|
||||
await rt.ReadAsync(CancellationToken.None);
|
||||
|
||||
ch.Sent[^1].ShouldBe(Convert.FromHexString(Dword1));
|
||||
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// N1 positive control: a refusal, then a 0xC1 reply, settles PER-ELEMENT — the n/32 element that came back is
|
||||
/// discarded and <c>Bits[n]</c> is re-read and decoded.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_refusal_then_a_per_element_reply_settles_the_element_path()
|
||||
{
|
||||
var reply = "D2000A00";
|
||||
var ch = Switchable(() => reply);
|
||||
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool));
|
||||
await rt.InitializeAsync(CancellationToken.None);
|
||||
|
||||
reply = "D2000000C10001";
|
||||
await rt.ReadAsync(CancellationToken.None);
|
||||
|
||||
ch.Sent[^2].ShouldBe(Convert.FromHexString(Dword1)); // asked DWORD-addressed, got a plain BOOL…
|
||||
ch.Sent[^1].ShouldBe(Convert.FromHexString(Element37)); // …so element 37 is re-read and decoded
|
||||
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// N1: a layout that changes after it was settled (an online edit re-types the array) re-settles from the reply
|
||||
/// type — it never decodes a DWORD reply to the ELEMENT request (DWORD n, byte 0) as the element's value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_settled_per_element_layout_resettles_when_the_reply_becomes_a_DWORD()
|
||||
{
|
||||
var reply = "D2000000C10000";
|
||||
var ch = Switchable(() => reply);
|
||||
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[36]", AbCipDataType.Bool));
|
||||
await rt.InitializeAsync(CancellationToken.None); // settles per-element
|
||||
|
||||
reply = "D2000000D300" + "01000000"; // byte 0 non-zero, bit 4 (element 36) clear
|
||||
await rt.ReadAsync(CancellationToken.None);
|
||||
|
||||
ch.Sent[^1].ShouldBe(Convert.FromHexString("5204" + "910442697473" + "2801" + "0100" + "00000000"));
|
||||
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(false); // bit 4 of DWORD 1, not "byte 0 != 0"
|
||||
}
|
||||
|
||||
/// <summary>N1 minimum guard: a scalar BOOL with no element bit answered as a packed DWORD is refused, not byte 0.</summary>
|
||||
[Fact]
|
||||
public async Task A_scalar_BOOL_answered_as_a_packed_DWORD_without_an_element_bit_is_refused()
|
||||
{
|
||||
using var rt = await ReadAsync(Replying("D300", "01000000"), Params("Flag", AbCipDataType.Bool));
|
||||
(Should.Throw<AbTransportException>(() => rt.DecodeValue(AbCipDataType.Bool, null))).Status.ShouldBe(AbStatus.TypeMismatch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// m4: a runtime whose init read failed on the wire (layout still unsettled) settles the layout by a read BEFORE
|
||||
/// it writes, so the write goes to the right element of a per-element array.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_write_on_an_unsettled_layout_reads_first_and_writes_the_element()
|
||||
{
|
||||
var reply = "D2000100"; // 0x01: not a refusal — init throws, layout unsettled
|
||||
var ch = Switchable(() => reply);
|
||||
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool));
|
||||
await Should.ThrowAsync<AbTransportException>(() => rt.InitializeAsync(CancellationToken.None));
|
||||
|
||||
reply = "D2000000C10000";
|
||||
rt.EncodeValue(AbCipDataType.Bool, null, true);
|
||||
await rt.WriteAsync(CancellationToken.None);
|
||||
|
||||
ch.Sent[^1].ShouldBe(Convert.FromHexString("4D04" + "910442697473" + "2825" + "C100" + "0100" + "01")); // Bits[37]
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a BOOL answered as a plain BOOL (0xC1) still decodes its byte.</summary>
|
||||
[Fact]
|
||||
public async Task A_BOOL_element_answered_as_BOOL_decodes_the_byte()
|
||||
{
|
||||
using var rt = await ReadAsync(Replying("C100", "01"), Params("Flag", AbCipDataType.Bool));
|
||||
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Real_returned_for_a_DInt_authored_tag_is_TypeMismatch()
|
||||
{
|
||||
@@ -129,14 +286,35 @@ public sealed class NativeAbCipTagRuntimeTests
|
||||
rt.GetStatus().ShouldBe((int)AbStatus.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_tag_fails_initialize_with_NotFound()
|
||||
/// <summary>
|
||||
/// Review 07 L4: a controller refusal on the existence read completes the initialization (the session works —
|
||||
/// throwing made the driver dispose + re-create the runtime, re-leasing the connection, every poll); the
|
||||
/// refusal stays readable and every later read reports it again.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("D2000400", AbStatus.NotFound)]
|
||||
[InlineData("D2000500", AbStatus.NotFound)]
|
||||
[InlineData("D2000A00", AbStatus.OutOfBounds)]
|
||||
[InlineData("D2000F00", AbStatus.NotAllowed)]
|
||||
[InlineData("D200FF010521", AbStatus.OutOfBounds)]
|
||||
public async Task A_refused_tag_completes_initialize_and_reports_the_refusal_on_read(string reply, AbStatus expected)
|
||||
{
|
||||
var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("D2000400")) };
|
||||
var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString(reply)) };
|
||||
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Nope", AbCipDataType.DInt));
|
||||
await rt.InitializeAsync(CancellationToken.None);
|
||||
rt.GetStatus().ShouldBe((int)expected);
|
||||
(await Should.ThrowAsync<AbTransportException>(() => rt.ReadAsync(CancellationToken.None)))
|
||||
.Status.ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a reply the transport cannot classify as a refusal still fails initialize.</summary>
|
||||
[Fact]
|
||||
public async Task An_unclassified_reply_still_fails_initialize()
|
||||
{
|
||||
var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("D2000100")) };
|
||||
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Nope", AbCipDataType.DInt));
|
||||
(await Should.ThrowAsync<AbTransportException>(() => rt.InitializeAsync(CancellationToken.None)))
|
||||
.Status.ShouldBe(AbStatus.NotFound);
|
||||
rt.GetStatus().ShouldBe((int)AbStatus.NotFound);
|
||||
.Status.ShouldBe(AbStatus.BadReply);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U10: an N-file array write (4 elements) round-trips every element on both transports and leaves the
|
||||
/// words either side untouched. Gated by <see cref="IntegrationWriteGate"/>. Doubles as the real-controller
|
||||
/// hardware gate: <c>AB_LEGACY_ARRAY_WRITE_BASE</c> (default <c>N7:1</c>) names the first of the SIX consecutive
|
||||
/// words it uses (base = "before" sentinel, base+1..base+4 = the array, base+5 = "after" sentinel); every one is
|
||||
/// read first and written back in a <c>finally</c>, so a run restores them byte-exact.
|
||||
/// </summary>
|
||||
[Collection(AbLegacyServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Simulator", "ab_server-PCCC")]
|
||||
public sealed class AbLegacyArrayWriteIntegrationTests(AbLegacyServerFixture sim)
|
||||
{
|
||||
/// <summary>The env var naming the first word of the six the test may overwrite (and restores).</summary>
|
||||
public const string BaseVar = "AB_LEGACY_ARRAY_WRITE_BASE";
|
||||
|
||||
/// <summary>Seeds the neighbours, writes an <c>int[]</c> to a 4-element Int array, reads it all back, restores.</summary>
|
||||
/// <param name="profile">The simulator profile.</param>
|
||||
/// <param name="transport">The transport stack the driver runs on.</param>
|
||||
[AbLegacyTheory]
|
||||
[MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
|
||||
public async Task N_file_array_write_round_trips_every_element(AbLegacyServerProfile profile, AbLegacyTransport transport)
|
||||
{
|
||||
if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
|
||||
IntegrationWriteGate.SkipUnlessWritesAllowed();
|
||||
|
||||
var baseText = Environment.GetEnvironmentVariable(BaseVar) is { Length: > 0 } b ? b : "N7:1";
|
||||
var baseAddr = AbLegacyAddress.TryParse(baseText, profile.Family);
|
||||
if (baseAddr is not { FileLetter: "N", FileNumber: int file, BitIndex: null, SubElement: null } || baseAddr.IsIndirect)
|
||||
throw new InvalidOperationException($"{BaseVar}='{baseText}' must be a plain N-file word such as N7:200.");
|
||||
string At(int offset) => $"N{file}:{baseAddr.WordNumber + offset}";
|
||||
|
||||
var deviceUri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
|
||||
var words = Enumerable.Range(0, 6)
|
||||
.Select(i => new AbLegacyTagDefinition($"W{i}", deviceUri, At(i), AbLegacyDataType.Int))
|
||||
.ToArray();
|
||||
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(deviceUri, profile.Family)],
|
||||
RawTags = AbLegacyRawTags.Entries(
|
||||
[.. words, new AbLegacyTagDefinition("Arr", deviceUri, At(1), AbLegacyDataType.Int, ArrayLength: 4)]),
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
Transport = transport,
|
||||
CoalesceReads = false, // read each word on its own, independent of the array path under test
|
||||
}, driverInstanceId: $"ablegacy-arraywrite-{profile.Family}-{transport}");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
string[] wordRefs = [.. words.Select(w => w.Name)];
|
||||
|
||||
if (profile.Family == AbLegacyPlcFamily.Plc5 && transport == AbLegacyTransport.Native)
|
||||
{
|
||||
// Native PLC-5 writes are refused before the wire (no capture of the PLC-5 write frames exists).
|
||||
(await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], ct)).Single()
|
||||
.StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
return;
|
||||
}
|
||||
|
||||
var original = await drv.ReadAsync(wordRefs, ct);
|
||||
original.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good, "the six words must exist before anything is written");
|
||||
try
|
||||
{
|
||||
(await drv.WriteAsync([new WriteRequest("W0", (short)111), new WriteRequest("W5", (short)666)], ct))
|
||||
.ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good);
|
||||
|
||||
// ScadaBridge shape: a CLR int[] to an Int16 array node.
|
||||
(await drv.WriteAsync([new WriteRequest("Arr", new[] { 10, -20, 30, 32767 })], ct)).Single()
|
||||
.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
|
||||
var arr = (await drv.ReadAsync(["Arr"], ct)).Single();
|
||||
arr.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
arr.Value.ShouldBe(new short[] { 10, -20, 30, 32767 });
|
||||
var back = await drv.ReadAsync(wordRefs, ct);
|
||||
back.Select(s => Convert.ToInt32(s.Value)).ShouldBe(new[] { 111, 10, -20, 30, 32767, 666 }); // per word, neighbours untouched
|
||||
|
||||
// A length mismatch is refused before the wire and changes nothing.
|
||||
(await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], ct)).Single()
|
||||
.StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
|
||||
(await drv.ReadAsync(["Arr"], ct)).Single().Value.ShouldBe(new short[] { 10, -20, 30, 32767 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Restore all six words exactly as found (CancellationToken.None: restore even if the run was cancelled).
|
||||
var restore = words.Select((w, i) => new WriteRequest(w.Name, Convert.ToInt16(original[i].Value))).ToArray();
|
||||
(await drv.WriteAsync(restore, CancellationToken.None))
|
||||
.ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good, "restoring the original words must succeed");
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
using System.Diagnostics;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 P7 (High), wire level: one driver serving a live <c>ab_server</c> device AND a dead device (a
|
||||
/// blackholed address, so every connect waits out the full timeout). Before the fix the dead device's 20 tags
|
||||
/// cost 20 x Timeout per poll and the live device was read once per cycle; now the live device reads Good in
|
||||
/// the same call, the dead device costs at most two timeouts, and the next poll does not touch it at all.
|
||||
/// Read-only.
|
||||
/// </summary>
|
||||
[Collection(AbLegacyServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Simulator", "ab_server-PCCC")]
|
||||
public sealed class AbLegacyDeadDeviceIsolationTests(AbLegacyServerFixture sim)
|
||||
{
|
||||
// TEST-NET-1 (RFC 5737): never routed, so a connect blackholes until the driver's timeout.
|
||||
private const string DeadUri = "ab://192.0.2.1:44818/1,0";
|
||||
|
||||
/// <summary>The live device is served promptly while a blackholed sibling is throttled.</summary>
|
||||
/// <param name="profile">The simulator profile.</param>
|
||||
/// <param name="transport">The transport stack the driver runs on.</param>
|
||||
[AbLegacyTheory]
|
||||
[MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
|
||||
public async Task Dead_device_does_not_stall_the_live_one(AbLegacyServerProfile profile, AbLegacyTransport transport)
|
||||
{
|
||||
if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
|
||||
|
||||
var liveUri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
|
||||
var timeout = TimeSpan.FromSeconds(2);
|
||||
var dead = Enumerable.Range(0, 20)
|
||||
.Select(i => new AbLegacyTagDefinition($"Dead{i}", DeadUri, $"N7:{i}", AbLegacyDataType.Int))
|
||||
.ToArray();
|
||||
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(liveUri, profile.Family), new AbLegacyDeviceOptions(DeadUri, profile.Family)],
|
||||
RawTags = AbLegacyRawTags.Entries(
|
||||
[new AbLegacyTagDefinition("Live", liveUri, "N7:0", AbLegacyDataType.Int), .. dead]),
|
||||
Timeout = timeout,
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
Transport = transport,
|
||||
}, driverInstanceId: $"ablegacy-p7-{profile.Family}-{transport}");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
string[] refs = ["Live", .. dead.Select(d => d.Name)];
|
||||
|
||||
// Two consecutive comms faults mark the device down. With coalescing on, the dead device's 20 tags are ONE
|
||||
// block (one fault per poll), so the window can open on the second poll rather than the first — either way
|
||||
// at most two timeouts are paid before the device stops being dialled.
|
||||
var total = TimeSpan.Zero;
|
||||
TimeSpan last = default;
|
||||
for (var poll = 0; poll < 3; poll++)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var r = await drv.ReadAsync(refs, ct);
|
||||
sw.Stop();
|
||||
total += last = sw.Elapsed;
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: the live device must be served in the same poll");
|
||||
r.Skip(1).ShouldAllBe(s => s.StatusCode != AbLegacyStatusMapper.Good);
|
||||
}
|
||||
|
||||
total.ShouldBeLessThan(timeout * 2.5, "a dead device may cost at most two timeouts per backoff window");
|
||||
last.ShouldBeLessThan(timeout, "inside the backoff window the dead device is not dialled");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 P7: a coalesced read of nearby N7 words and bits must decode exactly what per-tag reads return,
|
||||
/// on the wire, on both transports. Seeds distinct values first when writes are allowed (simulator only);
|
||||
/// otherwise compares whatever the simulator holds.
|
||||
/// </summary>
|
||||
[Collection(AbLegacyServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Simulator", "ab_server-PCCC")]
|
||||
public sealed class AbLegacyReadCoalescingIntegrationTests(AbLegacyServerFixture sim)
|
||||
{
|
||||
/// <summary>Coalesced and per-tag reads of the same tags agree value for value.</summary>
|
||||
/// <param name="profile">The simulator profile.</param>
|
||||
/// <param name="transport">The transport stack the driver runs on.</param>
|
||||
[AbLegacyTheory]
|
||||
[MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
|
||||
public async Task Coalesced_reads_match_single_reads(AbLegacyServerProfile profile, AbLegacyTransport transport)
|
||||
{
|
||||
if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
|
||||
AbLegacyTagDefinition[] tags =
|
||||
[
|
||||
new("W0", uri, "N7:0", AbLegacyDataType.Int),
|
||||
new("W1", uri, "N7:1", AbLegacyDataType.Int),
|
||||
new("W4", uri, "N7:4", AbLegacyDataType.Int),
|
||||
new("W4b3", uri, "N7:4/3", AbLegacyDataType.Bit),
|
||||
];
|
||||
string[] refs = [.. tags.Select(t => t.Name)];
|
||||
|
||||
AbLegacyDriver Make(bool coalesce) => new(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(uri, profile.Family)],
|
||||
RawTags = AbLegacyRawTags.Entries(tags),
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
Transport = transport,
|
||||
CoalesceReads = coalesce,
|
||||
}, driverInstanceId: $"ablegacy-coalesce-{coalesce}-{profile.Family}-{transport}");
|
||||
|
||||
await using var single = Make(coalesce: false);
|
||||
await using var coalesced = Make(coalesce: true);
|
||||
await single.InitializeAsync("{}", ct);
|
||||
await coalesced.InitializeAsync("{}", ct);
|
||||
|
||||
var writesRefused = profile.Family == AbLegacyPlcFamily.Plc5 && transport == AbLegacyTransport.Native;
|
||||
if (Environment.GetEnvironmentVariable(IntegrationWriteGate.AllowWritesVar) == "1"
|
||||
&& Environment.GetEnvironmentVariable(IntegrationWriteGate.DifferentialEndpointVar) is not { Length: > 0 }
|
||||
&& !writesRefused)
|
||||
{
|
||||
(await single.WriteAsync(
|
||||
[new WriteRequest("W0", (short)101), new WriteRequest("W1", (short)-202), new WriteRequest("W4", (short)0x0008)], ct))
|
||||
.ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good);
|
||||
}
|
||||
|
||||
var a = await single.ReadAsync(refs, ct);
|
||||
var b = await coalesced.ReadAsync(refs, ct);
|
||||
|
||||
a.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
|
||||
b.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
|
||||
b.Select(s => s.Value).ShouldBe(a.Select(s => s.Value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 r0 I1, on the wire: one coalesced member past the END of the file (the fixture's N7 holds 10
|
||||
/// words) makes the PLC refuse the block with a real transport status (libplctag: ERR_REMOTE_ERR). Its
|
||||
/// neighbours must still read Good on every poll, and the device must never be failed fast or degraded.
|
||||
/// </summary>
|
||||
/// <param name="profile">The simulator profile.</param>
|
||||
/// <param name="transport">The transport stack the driver runs on.</param>
|
||||
[AbLegacyTheory]
|
||||
[MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
|
||||
public async Task A_member_past_the_end_of_the_file_never_blacks_out_its_neighbours(
|
||||
AbLegacyServerProfile profile, AbLegacyTransport transport)
|
||||
{
|
||||
if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
|
||||
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(uri, profile.Family)],
|
||||
RawTags = AbLegacyRawTags.Entries(
|
||||
new AbLegacyTagDefinition("A", uri, "N7:7", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("B", uri, "N7:8", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("PastEnd", uri, "N7:12", AbLegacyDataType.Int)),
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
Transport = transport,
|
||||
}, driverInstanceId: $"ablegacy-pastend-{profile.Family}-{transport}");
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
|
||||
for (var poll = 0; poll < 3; poll++)
|
||||
{
|
||||
var r = await drv.ReadAsync(["A", "B", "PastEnd"], ct);
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: neighbour A");
|
||||
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: neighbour B");
|
||||
r[2].StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good, $"poll {poll}: the past-the-end tag");
|
||||
}
|
||||
// The refusal (libplctag: an opaque ERR_REMOTE_ERR; native: a mapped STS) is never a comms fault: the
|
||||
// answering neighbours keep the driver Healthy and the device is never failed fast.
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 r1 N1, on the wire: libplctag reports the past-the-end refusal as the OPAQUE ERR_REMOTE_ERR —
|
||||
/// the same code a bridge returns for a powered-off PLC behind it — so a device whose ONLY traffic is that
|
||||
/// reply must not look reachable: Degraded, <c>LastSuccessfulRead</c> frozen (the comms-loss watchdog's
|
||||
/// evidence). The native transport maps the STS itself and is only checked to stay non-Good.
|
||||
/// </summary>
|
||||
/// <param name="profile">The simulator profile.</param>
|
||||
/// <param name="transport">The transport stack the driver runs on.</param>
|
||||
[AbLegacyTheory]
|
||||
[MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
|
||||
public async Task An_opaque_only_device_never_proves_reachability(AbLegacyServerProfile profile, AbLegacyTransport transport)
|
||||
{
|
||||
if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
|
||||
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(uri, profile.Family)],
|
||||
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("PastEnd", uri, "N7:12", AbLegacyDataType.Int)),
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
Transport = transport,
|
||||
}, driverInstanceId: $"ablegacy-opaque-{profile.Family}-{transport}");
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
var initLastRead = drv.GetHealth().LastSuccessfulRead;
|
||||
await Task.Delay(20, ct);
|
||||
|
||||
for (var poll = 0; poll < 2; poll++)
|
||||
(await drv.ReadAsync(["PastEnd"], ct))[0].StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good);
|
||||
|
||||
if (transport == AbLegacyTransport.Libplctag)
|
||||
{
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldBe(initLastRead);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using libplctag;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins BOTH sides of the reply-classification line (review 07 r0 I1 + r1 N1).
|
||||
/// <list type="bullet">
|
||||
/// <item><b>No reply</b> (Timeout / BadConnection / Abort / Pending / unknown) — comms fault: evict, Degraded,
|
||||
/// counts toward fail-fast.</item>
|
||||
/// <item><b>Answered</b> (an allow-list: NotFound / NotAllowed / OutOfBounds / TooLarge / TooSmall / TypeMismatch /
|
||||
/// DeviceStateConflict) — proves reachability: per-tag Bad, Healthy, <c>LastSuccessfulRead</c> advances.</item>
|
||||
/// <item><b>Opaque</b> (BadReply / Partial — incl. libplctag's <c>ERR_REMOTE_ERR</c>, which folds a PLC refusal
|
||||
/// and a routing hop's "end device gone" together) — neither: per-tag Bad, never fail-fast, never blacks out
|
||||
/// coalesced neighbours, but NEVER advances <c>LastSuccessfulRead</c>; an opaque-only device degrades so the
|
||||
/// comms-loss watchdog can reach a dead PLC behind a bridge.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyAnsweredStatusTests
|
||||
{
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
|
||||
/// <summary>What the libplctag runtime's GetStatus() reports for ERR_REMOTE_ERR (through the real translation).</summary>
|
||||
private static readonly int RemoteErr = (int)LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorRemoteErr);
|
||||
|
||||
private static AbLegacyDriver NewDriver(FakeAbLegacyTagFactory factory, bool coalesce, params AbLegacyTagDefinition[] tags) =>
|
||||
new(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags = AbLegacyRawTags.Entries(tags),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
CoalesceReads = coalesce,
|
||||
}, "drv-i1", factory);
|
||||
|
||||
// ---- the classification table ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbStatus.NotFound)]
|
||||
[InlineData(AbStatus.NotAllowed)]
|
||||
[InlineData(AbStatus.OutOfBounds)]
|
||||
[InlineData(AbStatus.TooLarge)]
|
||||
[InlineData(AbStatus.TooSmall)]
|
||||
[InlineData(AbStatus.TypeMismatch)]
|
||||
[InlineData(AbStatus.DeviceStateConflict)]
|
||||
public void The_answered_class_is_an_allow_list(AbStatus status)
|
||||
{
|
||||
AbLegacyDriver.IsDeviceRefusal(status).ShouldBeTrue();
|
||||
AbLegacyDriver.IsOpaque(status).ShouldBeFalse();
|
||||
AbLegacyDriver.IsNoReply(status).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbStatus.BadReply)]
|
||||
[InlineData(AbStatus.Partial)]
|
||||
public void Unclassifiable_replies_are_opaque_not_answers(AbStatus status)
|
||||
{
|
||||
AbLegacyDriver.IsOpaque(status).ShouldBeTrue();
|
||||
AbLegacyDriver.IsDeviceRefusal(status).ShouldBeFalse();
|
||||
AbLegacyDriver.IsNoReply(status).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbStatus.Timeout)]
|
||||
[InlineData(AbStatus.BadConnection)]
|
||||
[InlineData(AbStatus.Abort)]
|
||||
[InlineData(AbStatus.Pending)]
|
||||
[InlineData((AbStatus)(-99))] // a code this build does not know: conservatively no reply
|
||||
public void Only_no_reply_statuses_are_comms_faults(AbStatus status)
|
||||
{
|
||||
AbLegacyDriver.IsNoReply(status).ShouldBeTrue();
|
||||
AbLegacyDriver.IsDeviceRefusal(status).ShouldBeFalse();
|
||||
AbLegacyDriver.IsOpaque(status).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_non_Ok_status_lands_in_at_most_one_class_and_Unsupported_in_none()
|
||||
{
|
||||
foreach (var s in Enum.GetValues<AbStatus>().Where(s => s != AbStatus.Ok))
|
||||
{
|
||||
var classes = new[] { AbLegacyDriver.IsNoReply(s), AbLegacyDriver.IsDeviceRefusal(s), AbLegacyDriver.IsOpaque(s) }
|
||||
.Count(c => c);
|
||||
if (s == AbStatus.Unsupported) classes.ShouldBe(0, "Unsupported is neutral");
|
||||
else classes.ShouldBe(1, s.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void libplctag_remote_errors_translate_to_opaque_not_to_an_answer_or_a_fault()
|
||||
{
|
||||
foreach (var st in new[] { Status.ErrorRemoteErr, Status.ErrorBadStatus })
|
||||
{
|
||||
var s = LibplctagLegacyStatusTranslation.ToAbStatus(st);
|
||||
AbLegacyDriver.IsOpaque(s).ShouldBeTrue(st.ToString());
|
||||
AbLegacyDriver.IsDeviceRefusal(s).ShouldBeFalse(st.ToString());
|
||||
AbLegacyDriver.IsNoReply(s).ShouldBeFalse(st.ToString());
|
||||
}
|
||||
// Positive controls on the other two sides.
|
||||
AbLegacyDriver.IsNoReply(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorTimeout)).ShouldBeTrue();
|
||||
AbLegacyDriver.IsDeviceRefusal(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorNotFound)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---- behaviour: the I1 side (an opaque member never blacks out its neighbours, never trips fail-fast) ----
|
||||
|
||||
[Fact]
|
||||
public async Task A_remote_error_member_never_blacks_out_its_coalesced_neighbours()
|
||||
{
|
||||
// N7:52 is past the end of the file: the block N7:48+5 and the single N7:52 both draw ERR_REMOTE_ERR.
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => p.ElementCount > 1 || p.TagName == "N7:52"
|
||||
? new FakeAbLegacyTag(p) { Status = RemoteErr }
|
||||
: new FakeAbLegacyTag(p) { Value = 48 },
|
||||
};
|
||||
var drv = NewDriver(factory, coalesce: true,
|
||||
new AbLegacyTagDefinition("A", Host, "N7:48", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("B", Host, "N7:49", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("C", Host, "N7:52", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
for (var poll = 0; poll < 4; poll++)
|
||||
{
|
||||
var r = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
|
||||
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
|
||||
r[2].StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good, $"poll {poll}");
|
||||
}
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy); // the neighbours answered
|
||||
drv.GetDeviceState(Host)!.IsThrottled(DateTime.UtcNow).ShouldBeFalse();
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consecutive_opaque_replies_do_not_trip_fail_fast_on_an_answering_device()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => p.TagName == "N7:9" ? new FakeAbLegacyTag(p) { Value = 9 } : new FakeAbLegacyTag(p) { Status = RemoteErr },
|
||||
};
|
||||
var drv = NewDriver(factory, coalesce: false,
|
||||
new AbLegacyTagDefinition("R1", Host, "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("R2", Host, "N7:3", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("R3", Host, "N7:6", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("Good", Host, "N7:9", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
for (var poll = 0; poll < 3; poll++)
|
||||
{
|
||||
var r = await drv.ReadAsync(["R1", "R2", "R3", "Good"], CancellationToken.None);
|
||||
r[3].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: never failed fast");
|
||||
}
|
||||
factory.Tags["N7:9"].ReadCount.ShouldBe(3);
|
||||
factory.Tags["N7:0"].InitializeCount.ShouldBe(1); // opaque handles are kept, not churned
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// ---- behaviour: the N1 side (an opaque reply never proves reachability) ----
|
||||
|
||||
[Fact]
|
||||
public async Task Remote_error_only_reads_never_advance_LastSuccessfulRead_and_degrade()
|
||||
{
|
||||
// A PLC behind a bridge is powered off: every request comes back fast with ERR_REMOTE_ERR.
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
|
||||
var drv = NewDriver(factory, coalesce: false,
|
||||
new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("Y", Host, "N7:5", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await drv.ReadAsync(["X", "Y"], CancellationToken.None)).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
|
||||
var healthyAt = drv.GetHealth().LastSuccessfulRead; // positive control: Good reads DID advance it
|
||||
healthyAt.ShouldNotBeNull();
|
||||
foreach (var t in factory.Tags.Values) t.Status = RemoteErr;
|
||||
await Task.Delay(5);
|
||||
|
||||
for (var poll = 0; poll < 3; poll++)
|
||||
(await drv.ReadAsync(["X", "Y"], CancellationToken.None)).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.BadCommunicationError);
|
||||
|
||||
var h = drv.GetHealth();
|
||||
h.LastSuccessfulRead.ShouldBe(healthyAt); // frozen: the comms-loss watchdog can reach this device
|
||||
h.State.ShouldBe(DriverState.Degraded);
|
||||
drv.GetDeviceState(Host)!.IsThrottled(DateTime.UtcNow).ShouldBeFalse(); // but no fail-fast window
|
||||
factory.Tags["N7:0"].ReadCount.ShouldBe(4); // every poll still asked
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private sealed class ThrowingTag(AbLegacyTagCreateParams p, AbStatus status) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public override Task ReadAsync(CancellationToken ct) => throw new AbTransportException(status, $"native {status}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbStatus.BadReply)]
|
||||
[InlineData(AbStatus.Partial)]
|
||||
public async Task A_native_unclassifiable_reply_is_not_counted_as_an_answer(AbStatus status)
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
|
||||
var drv = NewDriver(factory, coalesce: false, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
var healthyAt = drv.GetHealth().LastSuccessfulRead;
|
||||
drv.GetDeviceState(Host)!.Runtimes.TryRemove("X", out _);
|
||||
factory.Customise = p => new ThrowingTag(p, status);
|
||||
await Task.Delay(5);
|
||||
|
||||
var r = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.MapStatus(status));
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldBe(healthyAt);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Positive_control_a_real_device_refusal_alone_still_proves_reachability()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Status = (int)AbStatus.NotFound } };
|
||||
var drv = NewDriver(factory, coalesce: false, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var before = drv.GetHealth().LastSuccessfulRead;
|
||||
await Task.Delay(5);
|
||||
|
||||
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="DriverArrayWriteSupport"/>'s AbLegacy row against the REAL array encoder
|
||||
/// (<see cref="AbLegacyArrayWriteEncoder"/>), the same way the Modbus and S7 parity tests do: for every element
|
||||
/// type the server must advertise an array node writable exactly when the driver encodes it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ SKIPPED until follow-up F1 lands: the driver gained an array encoder in this lane (review 07 U10), but
|
||||
/// <see cref="DriverArrayWriteSupport"/> (Core.Abstractions, not owned by the AbLegacy lane) still lists AbLegacy as
|
||||
/// scalar-only, so every AbLegacy array node stays read-only and the encoder is unreachable from OPC UA. When F1
|
||||
/// adds the row, delete the Skip; this test then holds the table and the encoder together.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyArrayWriteSupportParityTests
|
||||
{
|
||||
public static TheoryData<AbLegacyDataType> AllTypes() => new(Enum.GetValues<AbLegacyDataType>());
|
||||
|
||||
private static string AddressFor(AbLegacyDataType t) => t switch
|
||||
{
|
||||
AbLegacyDataType.Float => "F8:0",
|
||||
AbLegacyDataType.Long => "L9:0",
|
||||
AbLegacyDataType.Bit => "B3:0",
|
||||
AbLegacyDataType.String => "ST9:0",
|
||||
AbLegacyDataType.TimerElement => "T4:0",
|
||||
AbLegacyDataType.CounterElement => "C5:0",
|
||||
AbLegacyDataType.ControlElement => "R6:0",
|
||||
_ => "N7:0",
|
||||
};
|
||||
|
||||
[Theory(Skip = "F1: DriverArrayWriteSupport has no AbLegacy row yet (Core.Abstractions is outside this lane)")]
|
||||
[MemberData(nameof(AllTypes))]
|
||||
public void Table_agrees_with_the_array_encoder(AbLegacyDataType dataType)
|
||||
{
|
||||
var json = $$"""{"address":"{{AddressFor(dataType)}}","dataType":"{{dataType}}","isArray":true,"arrayLength":2}""";
|
||||
var encoderAccepts = AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out _)
|
||||
&& AbLegacyArrayWriteEncoder.TryConvert(dataType, new object[] { 1, 1 }, 2, out _) == AbLegacyStatusMapper.Good;
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", json).ShouldBe(encoderAccepts, dataType.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The shapes the WRITE PATH refuses although their element type alone looks writable (review 07 r0 M6): a
|
||||
/// <c>/bit</c> or <c>.sub</c> address, an indirect address, a type that does not match its data file (I3), and
|
||||
/// a native PLC-5 device. The F1 row must answer false for every one — keyed on <c>dataType</c> alone it would
|
||||
/// advertise them writable. Skipped with the parity theory until F1 lands.
|
||||
/// </summary>
|
||||
[Theory(Skip = "F1: DriverArrayWriteSupport has no AbLegacy row yet (Core.Abstractions is outside this lane)")]
|
||||
[InlineData("""{"address":"N7:0/3","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
|
||||
[InlineData("""{"address":"T4:0.ACC","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
|
||||
[InlineData("""{"address":"N7:[N7:0]","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
|
||||
[InlineData("""{"address":"F8:0","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
|
||||
[InlineData("""{"address":"N7:0","dataType":"Long","isArray":true,"arrayLength":2}""", "{}")]
|
||||
[InlineData("""{"address":"N7:0","dataType":"Int","isArray":true,"arrayLength":2}""",
|
||||
"""{"transport":"Native","devices":[{"hostAddress":"ab://10.0.0.5/1,0","plcFamily":"Plc5"}]}""")]
|
||||
public void Table_refuses_the_shapes_the_write_path_refuses(string tagConfig, string driverConfig) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", tagConfig, driverConfig).ShouldBeFalse();
|
||||
|
||||
/// <summary>What the encoder accepts today — the row F1 must add (Int, AnalogInt, Long, Float on a matching file).</summary>
|
||||
[Theory]
|
||||
[InlineData(AbLegacyDataType.Int, true)]
|
||||
[InlineData(AbLegacyDataType.AnalogInt, true)]
|
||||
[InlineData(AbLegacyDataType.Long, true)]
|
||||
[InlineData(AbLegacyDataType.Float, true)]
|
||||
[InlineData(AbLegacyDataType.Bit, false)]
|
||||
[InlineData(AbLegacyDataType.String, false)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, false)]
|
||||
public void Encoder_accepts_exactly_the_flat_numeric_element_types(AbLegacyDataType dataType, bool accepts) =>
|
||||
(AbLegacyArrayWriteEncoder.TryConvert(dataType, new object[] { 1, 1 }, 2, out _) == AbLegacyStatusMapper.Good)
|
||||
.ShouldBe(accepts);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
using System.Buffers.Binary;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U10 (user decision "implement array encode now"): an array write used to encode a SCALAR — an
|
||||
/// <c>int[]</c> threw <see cref="InvalidCastException"/> into <c>BadTypeMismatch</c> + Degraded, and a scalar to an
|
||||
/// array tag wrote element 0 (native) or re-sent the stale last-read buffer (libplctag). The driver now encodes
|
||||
/// exactly <c>count</c> elements at the element width, converting each element (ScadaBridge writes CLR lists
|
||||
/// such as <c>int[]</c> to Int16 nodes), and refuses a length mismatch with <c>BadOutOfRange</c>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyArrayWriteTests
|
||||
{
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
|
||||
private sealed class RecordingTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public Array? Encoded { get; private set; }
|
||||
public AbLegacyDataType? EncodedType { get; private set; }
|
||||
|
||||
public override void EncodeArray(AbLegacyDataType type, Array values)
|
||||
{
|
||||
EncodedType = type;
|
||||
Encoded = values;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(AbLegacyDriver Drv, FakeAbLegacyTagFactory Factory)> NewAsync(
|
||||
AbLegacyDataType type, string address, int length)
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new RecordingTag(p) };
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("Arr", Host, address, type, ArrayLength: length)),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-u10", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
public static TheoryData<string> IntPayloads => ["int[]", "short[]", "List<int>", "object[]"];
|
||||
|
||||
private static object Payload(string kind) => kind switch
|
||||
{
|
||||
"int[]" => new[] { 1, -2, 3 }, // ScadaBridge: CLR int[] to an Int16 node
|
||||
"short[]" => new short[] { 1, -2, 3 },
|
||||
"List<int>" => new List<int> { 1, -2, 3 },
|
||||
_ => new object[] { 1L, (short)-2, 3.0 },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(IntPayloads))]
|
||||
public async Task Int_array_write_converts_each_element_and_encodes_count_elements(string kind)
|
||||
{
|
||||
var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 3);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", Payload(kind))], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
var tag = (RecordingTag)f.Tags["N7:0"];
|
||||
tag.CreationParams.ElementCount.ShouldBe(3);
|
||||
tag.EncodedType.ShouldBe(AbLegacyDataType.Int);
|
||||
tag.Encoded.ShouldBe(new short[] { 1, -2, 3 });
|
||||
tag.WriteCount.ShouldBe(1);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>Review 07 r0 I3: every data file type encodes at ITS element width (one case per file).</summary>
|
||||
[Theory]
|
||||
[InlineData("N7:0", AbLegacyDataType.Int, AbLegacyDataType.Int)]
|
||||
[InlineData("N7:0", AbLegacyDataType.AnalogInt, AbLegacyDataType.Int)]
|
||||
[InlineData("B3:0", AbLegacyDataType.Int, AbLegacyDataType.Int)]
|
||||
[InlineData("A9:0", AbLegacyDataType.Int, AbLegacyDataType.Int)]
|
||||
[InlineData("L9:0", AbLegacyDataType.Long, AbLegacyDataType.Long)]
|
||||
[InlineData("F8:0", AbLegacyDataType.Float, AbLegacyDataType.Float)]
|
||||
public async Task Each_file_type_encodes_at_the_file_element_width(string address, AbLegacyDataType authored, AbLegacyDataType fileWidth)
|
||||
{
|
||||
var (drv, f) = await NewAsync(authored, address, 2);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2 })], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
var tag = (RecordingTag)f.Tags[address];
|
||||
tag.EncodedType.ShouldBe(fileWidth);
|
||||
tag.Encoded!.GetType().ShouldBe(fileWidth switch
|
||||
{
|
||||
AbLegacyDataType.Long => typeof(int[]),
|
||||
AbLegacyDataType.Float => typeof(float[]),
|
||||
_ => typeof(short[]),
|
||||
});
|
||||
tag.WriteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 r0 I3: an array whose authored type does not match its data file (Int on F8, Long on N7, Float on
|
||||
/// L9) used to be encoded at the AUTHORED width into the file-typed buffer and sent — garbage in the
|
||||
/// neighbouring elements, answered Good. It is now refused BadTypeMismatch before any runtime exists.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("F8:0", AbLegacyDataType.Int)]
|
||||
[InlineData("N7:0", AbLegacyDataType.Long)]
|
||||
[InlineData("N7:0", AbLegacyDataType.Float)]
|
||||
[InlineData("L9:0", AbLegacyDataType.Float)]
|
||||
[InlineData("L9:0", AbLegacyDataType.Int)]
|
||||
public async Task A_type_that_does_not_match_its_file_is_refused_and_never_written(string address, AbLegacyDataType authored)
|
||||
{
|
||||
var (drv, f) = await NewAsync(authored, address, 4);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
|
||||
f.Tags.ShouldBeEmpty(); // nothing created, nothing on the wire
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
AbLegacyTagDefinitionFactory.Inspect(
|
||||
$$"""{"address":"{{address}}","dataType":"{{authored}}","isArray":true,"arrayLength":4}""")
|
||||
.ShouldContain(w => w.Contains("element width")); // and the deploy gate names it
|
||||
}
|
||||
|
||||
/// <summary>Review 07 r1 N5: the file letter of an indirect address is static, so the deploy gate checks it too.</summary>
|
||||
[Theory]
|
||||
[InlineData("N7:[N7:0]", "Float", true)]
|
||||
[InlineData("F[N7:0]:2", "Int", true)]
|
||||
[InlineData("N7:[N7:0]", "Int", false)]
|
||||
[InlineData("F[N7:0]:2", "Float", false)]
|
||||
public void An_indirect_array_type_file_mismatch_is_reported_at_the_gate(string address, string dataType, bool mismatch)
|
||||
{
|
||||
var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":2}""";
|
||||
Enum.TryParse<AbLegacyDataType>(dataType, out var type).ShouldBeTrue();
|
||||
|
||||
(AbLegacyTagDefinitionFactory.ArrayFileMismatch(type, address) is not null).ShouldBe(mismatch);
|
||||
AbLegacyTagDefinitionFactory.Inspect(json).Any(w => w.Contains("element width")).ShouldBe(mismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Positive_control_a_Float_array_on_an_F_file_is_written()
|
||||
{
|
||||
var (drv, f) = await NewAsync(AbLegacyDataType.Float, "F8:0", 4);
|
||||
(await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], CancellationToken.None))[0]
|
||||
.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
((RecordingTag)f.Tags["F8:0"]).Encoded.ShouldBe(new[] { 1f, 2f, 3f, 4f });
|
||||
AbLegacyTagDefinitionFactory.Inspect("""{"address":"F8:0","dataType":"Float","isArray":true,"arrayLength":4}""")
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Float_and_Long_arrays_encode_at_their_element_width()
|
||||
{
|
||||
var (fd, ff) = await NewAsync(AbLegacyDataType.Float, "F8:0", 2);
|
||||
(await fd.WriteAsync([new WriteRequest("Arr", new[] { 1.5, 2 })], CancellationToken.None))[0]
|
||||
.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
((RecordingTag)ff.Tags["F8:0"]).Encoded.ShouldBe(new[] { 1.5f, 2f });
|
||||
|
||||
var (ld, lf) = await NewAsync(AbLegacyDataType.Long, "L9:0", 2);
|
||||
(await ld.WriteAsync([new WriteRequest("Arr", new short[] { 7, -8 })], CancellationToken.None))[0]
|
||||
.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
((RecordingTag)lf.Tags["L9:0"]).Encoded.ShouldBe(new[] { 7, -8 });
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2)]
|
||||
[InlineData(4)]
|
||||
public async Task Length_mismatch_is_BadOutOfRange_and_nothing_is_sent(int supplied)
|
||||
{
|
||||
var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 3);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", new int[supplied])], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
|
||||
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Element_overflow_is_BadOutOfRange_and_nothing_is_sent()
|
||||
{
|
||||
var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 2);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 70000 })], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
|
||||
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(5)]
|
||||
[InlineData("1,2")]
|
||||
public async Task Non_array_payload_or_unconvertible_element_is_BadTypeMismatch(object payload)
|
||||
{
|
||||
var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 2);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", payload)], CancellationToken.None);
|
||||
var e = await drv.WriteAsync([new WriteRequest("Arr", new object[] { 1, "x" })], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
|
||||
e[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
|
||||
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_array_write_is_refused_BadNotSupported_before_any_runtime()
|
||||
{
|
||||
// Bit arrays are bit-packed in words: an encode of N bits would rewrite whole words from a stale read.
|
||||
var (drv, f) = await NewAsync(AbLegacyDataType.Bit, "B3:0", 16);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", new bool[16])], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
f.Tags.ShouldBeEmpty();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Native_array_encode_writes_every_element_at_its_offset()
|
||||
{
|
||||
var channel = new FakeCipChannel();
|
||||
await using var scheduler = new RequestScheduler(channel, allowPacking: false);
|
||||
var rt = new NativeAbLegacyTagRuntime(
|
||||
new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", "slc500", "N7:0", TimeSpan.FromSeconds(2), 3),
|
||||
scheduler, 0xF33D, 0x21504345);
|
||||
channel.Respond = req => Task.FromResult(NativeEcho(req));
|
||||
|
||||
rt.EncodeArray(AbLegacyDataType.Int, new short[] { 10, -1, 300 });
|
||||
await rt.WriteAsync(CancellationToken.None);
|
||||
|
||||
var data = channel.Sent.Single()[23..];
|
||||
data.Length.ShouldBe(6);
|
||||
BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(0)).ShouldBe((short)10);
|
||||
BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(2)).ShouldBe((short)-1);
|
||||
BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(4)).ShouldBe((short)300);
|
||||
rt.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Native_array_encode_refuses_a_length_other_than_the_runtime_count()
|
||||
{
|
||||
var channel = new FakeCipChannel();
|
||||
var scheduler = new RequestScheduler(channel, allowPacking: false);
|
||||
var rt = new NativeAbLegacyTagRuntime(
|
||||
new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", "slc500", "F8:0", TimeSpan.FromSeconds(2), 3),
|
||||
scheduler, 0xF33D, 0x21504345);
|
||||
|
||||
Should.Throw<ArgumentException>(() => rt.EncodeArray(AbLegacyDataType.Float, new float[2]));
|
||||
rt.Dispose();
|
||||
}
|
||||
|
||||
// Execute PCCC reply echoing the request's requestor id and TNS with STS 0 and no data.
|
||||
private static byte[] NativeEcho(byte[] req)
|
||||
{
|
||||
var body = new List<byte>(req[6..13]) { 0x4F, 0x00, req[15], req[16] };
|
||||
return FakeCipChannel.Reply(0x4B, 0x00, [.. body]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 02 P1: AbLegacy ran <see cref="PollGroupEngine"/> but did not implement <see cref="IBatchSubscribable"/>,
|
||||
/// so every changed tag paid its own trip through the actor chain. It now raises one
|
||||
/// <see cref="IBatchSubscribable.OnDataChangeBatch"/> per poll cycle, alongside the per-tag event.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyBatchSubscribeTests
|
||||
{
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
|
||||
[Fact]
|
||||
public async Task One_batch_per_poll_carries_every_changed_tag_and_per_tag_events_still_fire()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = p.TagName == "N7:0" ? 1 : 2 } };
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags = AbLegacyRawTags.Entries(
|
||||
new AbLegacyTagDefinition("A", Host, "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("B", Host, "N9:0", AbLegacyDataType.Int)),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-batch", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
IBatchSubscribable batching = drv; // the capability the actor prefers
|
||||
|
||||
var batches = new System.Collections.Concurrent.ConcurrentQueue<DataChangeBatchEventArgs>();
|
||||
var singles = new System.Collections.Concurrent.ConcurrentQueue<DataChangeEventArgs>();
|
||||
batching.OnDataChangeBatch += (_, e) => batches.Enqueue(e);
|
||||
drv.OnDataChange += (_, e) => singles.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (batches.IsEmpty && DateTime.UtcNow < deadline) await Task.Delay(10);
|
||||
|
||||
batches.TryPeek(out var first).ShouldBeTrue();
|
||||
first!.SubscriptionHandle.ShouldBe(handle);
|
||||
first.Items.Select(i => i.FullReference).ShouldBe(new[] { "A", "B" }, ignoreOrder: true);
|
||||
first.Items.Single(i => i.FullReference == "A").Snapshot.Value.ShouldBe(1);
|
||||
singles.Count.ShouldBeGreaterThanOrEqualTo(2); // positive control: the legacy per-tag event still fires
|
||||
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -205,7 +205,7 @@ public sealed class AbLegacyBitRmwTests
|
||||
var firstResult = await drv.WriteAsync([new WriteRequest("Bit2", true)], CancellationToken.None);
|
||||
firstResult.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
|
||||
// Teardown — DisposeRuntimes must dispose + clear the RMW lock map.
|
||||
// Teardown — retirement drops the device state (and its RMW lock map) with it.
|
||||
await drv.DisposeAsync();
|
||||
|
||||
// Second session: re-init on a fresh driver instance (mirrors ReinitializeAsync behavior).
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 H16: an array of String/Timer/Counter/Control was accepted by the mapper, so every poll created a
|
||||
/// handle (one init read), threw <see cref="NotSupportedException"/> from the decode, disposed the handle and
|
||||
/// latched Degraded. A deterministic refusal is now rejected at the mapper; a runtime
|
||||
/// <see cref="NotSupportedException"/> that still occurs is per-tag <c>BadNotSupported</c>, keeps its handle,
|
||||
/// warns once and leaves driver health alone.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyDeterministicRefusalTests
|
||||
{
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
|
||||
[Theory]
|
||||
[InlineData("String", "ST9:0")]
|
||||
[InlineData("TimerElement", "T4:0")]
|
||||
[InlineData("CounterElement", "C5:0")]
|
||||
[InlineData("ControlElement", "R6:0")]
|
||||
public void Mapper_rejects_arrays_of_structured_element_types(string dataType, string address)
|
||||
{
|
||||
var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}""";
|
||||
|
||||
AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out _).ShouldBeFalse();
|
||||
AbLegacyTagDefinitionFactory.Inspect(json).ShouldContain(w => w.Contains("not supported as an array"));
|
||||
// Positive control: the same type as a SCALAR is still accepted.
|
||||
AbLegacyTagDefinitionFactory.FromTagConfig(
|
||||
$$"""{"address":"{{address}}","dataType":"{{dataType}}"}""", "p", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Int", "N7:0")]
|
||||
[InlineData("Float", "F8:0")]
|
||||
[InlineData("Long", "L9:0")]
|
||||
[InlineData("Bit", "B3:0")]
|
||||
public void Mapper_keeps_arrays_of_flat_element_types(string dataType, string address)
|
||||
{
|
||||
var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}""";
|
||||
AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out var def).ShouldBeTrue();
|
||||
def.ArrayLength.ShouldBe(4);
|
||||
AbLegacyTagDefinitionFactory.Inspect(json).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejected_array_tag_is_skipped_with_its_reason_and_creates_no_runtime()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags =
|
||||
[
|
||||
new RawTagEntry("Strs", """{"address":"ST9:0","dataType":"String","isArray":true,"arrayLength":3}""", false, Host),
|
||||
new RawTagEntry("Good", """{"address":"N7:0","dataType":"Int"}""", false, Host),
|
||||
],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-h16", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["Strs", "Good"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control
|
||||
factory.Tags.Keys.ShouldNotContain("ST9:0");
|
||||
var skipped = drv.GetSkippedTags().ShouldNotBeNull();
|
||||
skipped.Sample.ShouldContain(s => s.RawPath == "Strs" && s.Reason.Contains("not supported as an array"));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private sealed class RefusingDecodeTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public override object? DecodeArray(AbLegacyDataType type, int count) =>
|
||||
throw new NotSupportedException("decode refused");
|
||||
|
||||
public override void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) =>
|
||||
throw new NotSupportedException("encode refused");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Runtime_NotSupported_on_read_is_BadNotSupported_keeps_handle_no_health_warns_once()
|
||||
{
|
||||
var logger = new AbLegacyTestLogger();
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => p.TagName == "N7:0" ? new RefusingDecodeTag(p) : new FakeAbLegacyTag(p) { Value = 7 },
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags = AbLegacyRawTags.Entries(
|
||||
new AbLegacyTagDefinition("Arr", Host, "N7:0", AbLegacyDataType.Int, ArrayLength: 4),
|
||||
new AbLegacyTagDefinition("Good", Host, "N9:0", AbLegacyDataType.Int)),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-h16", factory, logger);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var first = await drv.ReadAsync(["Arr", "Good"], CancellationToken.None);
|
||||
var second = await drv.ReadAsync(["Arr", "Good"], CancellationToken.None);
|
||||
|
||||
first[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
second[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
first[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control: the refusal is per tag
|
||||
var tag = factory.Tags["N7:0"];
|
||||
tag.InitializeCount.ShouldBe(1); // handle kept, not churned
|
||||
tag.Disposed.ShouldBeFalse();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
logger.Warnings.Count(w => w.Contains("Arr") && w.Contains("decode refused")).ShouldBe(1);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Runtime_NotSupported_on_write_is_BadNotSupported_with_no_health_write()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new RefusingDecodeTag(p) { Value = 1 } };
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-h16", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
|
||||
var w = await drv.WriteAsync([new WriteRequest("X", 5)], CancellationToken.None);
|
||||
|
||||
w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
factory.Tags["N7:0"].Disposed.ShouldBeFalse();
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 P7 (High): reads were strictly serial across every device with no per-device backoff, so one dead
|
||||
/// device cost <c>Timeout</c> per tag per poll and starved every healthy device — long enough to trip the
|
||||
/// comms-loss watchdog against a reachable one. Devices are now read in parallel (bounded), each device pass
|
||||
/// stops paying for a dead device after two consecutive comms faults and opens a per-device
|
||||
/// <see cref="ConnectionBackoff"/> window, and cold connects are single-flight per device.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyDeviceIsolationTests
|
||||
{
|
||||
private const string HostA = "ab://10.0.0.5/1,0";
|
||||
private const string HostB = "ab://10.0.0.6/1,0";
|
||||
|
||||
/// <summary>A tag on a dead device: each wire operation burns <paramref name="cost"/> and then fails.</summary>
|
||||
private sealed class DeadTag(AbLegacyTagCreateParams p, TimeSpan cost) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public static int WireOps;
|
||||
|
||||
public override async Task InitializeAsync(CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref WireOps);
|
||||
await Task.Delay(cost, ct);
|
||||
throw new System.Net.Sockets.SocketException(10060);
|
||||
}
|
||||
|
||||
public override async Task ReadAsync(CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref WireOps);
|
||||
await Task.Delay(cost, ct);
|
||||
throw new System.Net.Sockets.SocketException(10060);
|
||||
}
|
||||
}
|
||||
|
||||
private static AbLegacyDriver NewDriver(FakeAbLegacyTagFactory factory, params AbLegacyTagDefinition[] tags) =>
|
||||
new(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(HostA), new AbLegacyDeviceOptions(HostB)],
|
||||
RawTags = AbLegacyRawTags.Entries(tags),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
CoalesceReads = false, // per-tag isolation is under test here; coalescing has its own tests
|
||||
}, "drv-p7", factory);
|
||||
|
||||
private static AbLegacyTagDefinition[] Tags(string prefix, string host, int n, string file = "N7") =>
|
||||
[.. Enumerable.Range(0, n).Select(i => new AbLegacyTagDefinition($"{prefix}{i}", host, $"{file}:{i * 3}", AbLegacyDataType.Int))];
|
||||
|
||||
[Fact]
|
||||
public async Task Dead_device_costs_at_most_two_timeouts_then_fails_fast_and_never_stalls_the_healthy_one()
|
||||
{
|
||||
var cost = TimeSpan.FromMilliseconds(150);
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => p.Gateway == "10.0.0.6" ? new DeadTag(p, cost) : new FakeAbLegacyTag(p) { Value = 7 },
|
||||
};
|
||||
var a = Tags("A", HostA, 5);
|
||||
var b = Tags("B", HostB, 20);
|
||||
var drv = NewDriver(factory, [.. a, .. b]);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var refs = a.Concat(b).Select(t => t.Name).ToList();
|
||||
var before = drv.GetHealth().LastSuccessfulRead;
|
||||
Interlocked.Exchange(ref DeadTag.WireOps, 0);
|
||||
|
||||
var first = await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
// The proof is the wire-op count, not a stopwatch: serial-with-no-backoff would dial B 20 times.
|
||||
Volatile.Read(ref DeadTag.WireOps).ShouldBeLessThanOrEqualTo(2);
|
||||
first.Take(5).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); // positive control: A is served
|
||||
first.Skip(5).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.BadCommunicationError);
|
||||
var health = drv.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Degraded);
|
||||
health.LastSuccessfulRead.ShouldNotBeNull();
|
||||
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before ?? DateTime.MinValue); // A's success advances it
|
||||
|
||||
// Inside B's backoff window the next poll touches B's wire not at all.
|
||||
var opsAfterFirst = Volatile.Read(ref DeadTag.WireOps);
|
||||
var second = await drv.ReadAsync(refs, CancellationToken.None);
|
||||
Volatile.Read(ref DeadTag.WireOps).ShouldBe(opsAfterFirst);
|
||||
second.Take(5).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Devices_are_read_in_parallel()
|
||||
{
|
||||
// Device A's read cannot complete until device B's read has STARTED: a serial walk deadlocks here.
|
||||
var bStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => p.Gateway == "10.0.0.5"
|
||||
? new GatedTag(p, wait: bStarted.Task) { Value = 1 }
|
||||
: new GatedTag(p, signal: bStarted) { Value = 2 },
|
||||
};
|
||||
var drv = NewDriver(factory,
|
||||
new AbLegacyTagDefinition("A", HostA, "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("B", HostB, "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
r[0].Value.ShouldBe(1);
|
||||
r[1].Value.ShouldBe(2);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private sealed class GatedTag(AbLegacyTagCreateParams p, Task? wait = null, TaskCompletionSource? signal = null)
|
||||
: FakeAbLegacyTag(p)
|
||||
{
|
||||
public override async Task ReadAsync(CancellationToken ct)
|
||||
{
|
||||
signal?.TrySetResult();
|
||||
if (wait is not null) await wait.WaitAsync(ct);
|
||||
await base.ReadAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task One_timing_out_tag_does_not_blacken_the_rest_of_its_device()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => p.TagName == "N7:0"
|
||||
? new FakeAbLegacyTag(p) { ThrowOnRead = true, Exception = new TimeoutException() }
|
||||
: new FakeAbLegacyTag(p) { Value = 5 },
|
||||
};
|
||||
var drv = NewDriver(factory, Tags("A", HostA, 4));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
for (var poll = 0; poll < 3; poll++)
|
||||
{
|
||||
var r = await drv.ReadAsync(["A0", "A1", "A2", "A3"], CancellationToken.None);
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
r.Skip(1).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good, $"poll {poll}");
|
||||
}
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Device_refusal_is_per_tag_Bad_advances_LastSuccessfulRead_and_keeps_the_handle()
|
||||
{
|
||||
var logger = new AbLegacyTestLogger();
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbLegacyTag(p) { Status = (int)AbStatus.NotFound },
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(HostA)],
|
||||
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", HostA, "N7:0", AbLegacyDataType.Int)),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-p7", factory, logger);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var initHealth = drv.GetHealth();
|
||||
await Task.Delay(20);
|
||||
|
||||
var r1 = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
var r2 = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
r1[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
r2[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
var health = drv.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Healthy); // the device answered: not a comms fault
|
||||
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(initHealth.LastSuccessfulRead!.Value);
|
||||
factory.Tags["N7:0"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["N7:0"].ReadCount.ShouldBe(2); // positive control: both polls really asked the device
|
||||
logger.Warnings.Count(w => w.Contains("Reference=X")).ShouldBe(1);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cold_connects_on_one_device_are_single_flight()
|
||||
{
|
||||
var inFlight = 0;
|
||||
var maxInFlight = 0;
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => new CountingInitTag(p, () =>
|
||||
{
|
||||
var now = Interlocked.Increment(ref inFlight);
|
||||
int seen;
|
||||
while (now > (seen = Volatile.Read(ref maxInFlight)) && Interlocked.CompareExchange(ref maxInFlight, now, seen) != seen) { }
|
||||
}, () => Interlocked.Decrement(ref inFlight)) { Value = 1 },
|
||||
};
|
||||
var tags = Tags("A", HostA, 6);
|
||||
var drv = NewDriver(factory, tags);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Three concurrent callers (poll loop, server read, write) each hitting different cold tags of one device.
|
||||
await Task.WhenAll(
|
||||
drv.ReadAsync(["A0", "A1"], CancellationToken.None),
|
||||
drv.ReadAsync(["A2", "A3"], CancellationToken.None),
|
||||
drv.WriteAsync([new WriteRequest("A4", 3), new WriteRequest("A5", 4)], CancellationToken.None));
|
||||
|
||||
maxInFlight.ShouldBe(1);
|
||||
factory.Tags.Values.Count(t => t.InitializeCount == 1).ShouldBe(6); // positive control: all six connected
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private sealed class CountingInitTag(AbLegacyTagCreateParams p, Action enter, Action exit) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public override async Task InitializeAsync(CancellationToken ct)
|
||||
{
|
||||
enter();
|
||||
try
|
||||
{
|
||||
await Task.Delay(30, ct);
|
||||
await base.InitializeAsync(ct);
|
||||
}
|
||||
finally { exit(); }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_recovered_device_is_read_again_once_its_window_expires()
|
||||
{
|
||||
var dead = true;
|
||||
var creates = 0;
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p =>
|
||||
{
|
||||
Interlocked.Increment(ref creates);
|
||||
return dead
|
||||
? new FakeAbLegacyTag(p) { ThrowOnInitialize = true, Exception = new System.Net.Sockets.SocketException(10061) }
|
||||
: new FakeAbLegacyTag(p) { Value = 9 };
|
||||
},
|
||||
};
|
||||
var drv = NewDriver(factory, new AbLegacyTagDefinition("B", HostB, "N7:0", AbLegacyDataType.Int));
|
||||
var now = new DateTime(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
|
||||
drv.Clock = () => now;
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Two consecutive comms faults mark the device down and open its first (1 s) window.
|
||||
(await drv.ReadAsync(["B"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
(await drv.ReadAsync(["B"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
var attempts = Volatile.Read(ref creates);
|
||||
dead = false;
|
||||
// Inside the window: fails fast without a connect attempt, although the device is back.
|
||||
(await drv.ReadAsync(["B"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
Volatile.Read(ref creates).ShouldBe(attempts);
|
||||
now = now.AddMilliseconds(1100); // cross the first (1 s) window on the driver's clock, no sleeping
|
||||
|
||||
var recovered = await drv.ReadAsync(["B"], CancellationToken.None);
|
||||
recovered[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
recovered[0].Value.ShouldBe(9);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,40 @@ public sealed class AbLegacyDriverProbeTests
|
||||
LibplctagLegacyName.Translate(status!).ShouldBe("S2:0");
|
||||
}
|
||||
|
||||
private sealed class StallingTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public override Task InitializeAsync(CancellationToken ct) => Task.Delay(Timeout.Infinite, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The authored <c>probeTimeoutSeconds</c> ("Admin UI probe timeout") was parsed by nothing — Test Connect ran
|
||||
/// on the caller's budget alone. It now tightens that budget (never raises it); absent or non-positive keeps
|
||||
/// the caller's budget (the positive control).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(",\"probeTimeoutSeconds\":1", "1s")]
|
||||
[InlineData(",\"probeTimeoutSeconds\":30", "2s")]
|
||||
[InlineData("", "2s")]
|
||||
[InlineData(",\"probeTimeoutSeconds\":0", "2s")]
|
||||
public async Task Configured_probe_timeout_tightens_the_caller_budget(string key, string expectedBudget)
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try
|
||||
{
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new StallingTag(p) };
|
||||
var json = $"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\"}}]{key}}}";
|
||||
|
||||
var result = await new AbLegacyDriverProbe(factory)
|
||||
.ProbeAsync(json, TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldBe($"Probe timed out after {expectedBudget}.");
|
||||
}
|
||||
finally { listener.Stop(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unparseable_probe_address_is_a_clean_failure_not_an_exception()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 r0 I2 + M1/M2/M4/M10: any evidence that the device answered — a Good value, a device refusal, a Good
|
||||
/// or refused write — restores Healthy and advances <c>LastSuccessfulRead</c>; a no-reply failure on either path
|
||||
/// degrades; writes respect the device's backoff window; teardown health is epoch-guarded.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyHealthEvidenceTests
|
||||
{
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
|
||||
private sealed class SwitchableFactory : IAbLegacyTagFactory
|
||||
{
|
||||
public Func<AbLegacyTagCreateParams, FakeAbLegacyTag> Make { get; set; } = p => new FakeAbLegacyTag(p) { Value = 1 };
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, FakeAbLegacyTag> Tags { get; } = new();
|
||||
|
||||
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p) => Tags[p.TagName] = Make(p);
|
||||
}
|
||||
|
||||
private static FakeAbLegacyTag Dead(AbLegacyTagCreateParams p) =>
|
||||
new(p) { ThrowOnRead = true, ThrowOnWrite = true, Exception = new System.Net.Sockets.SocketException(10060) };
|
||||
|
||||
private static AbLegacyDriver NewDriver(IAbLegacyTagFactory f, params AbLegacyTagDefinition[] tags) =>
|
||||
new(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags = AbLegacyRawTags.Entries(tags),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
CoalesceReads = false,
|
||||
}, "drv-health", f);
|
||||
|
||||
private static async Task<AbLegacyDriver> DegradedDriverAsync(SwitchableFactory f, params AbLegacyTagDefinition[] tags)
|
||||
{
|
||||
var drv = NewDriver(f, tags);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
f.Make = Dead;
|
||||
(await drv.ReadAsync([tags[0].Name], CancellationToken.None))[0].StatusCode
|
||||
.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded); // precondition
|
||||
return drv;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refusal_only_read_after_a_fault_restores_Healthy_and_advances_LastSuccessfulRead()
|
||||
{
|
||||
var f = new SwitchableFactory();
|
||||
var drv = await DegradedDriverAsync(f, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int));
|
||||
var before = drv.GetHealth().LastSuccessfulRead;
|
||||
f.Make = p => new FakeAbLegacyTag(p) { Status = (int)AbStatus.NotFound };
|
||||
await Task.Delay(5);
|
||||
|
||||
var r = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
var h = drv.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Healthy);
|
||||
h.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)] // a Good write
|
||||
[InlineData((int)AbStatus.NotAllowed)] // a write the device refused
|
||||
public async Task A_good_or_refused_write_after_a_fault_restores_Healthy(int writeStatus)
|
||||
{
|
||||
var f = new SwitchableFactory();
|
||||
var drv = await DegradedDriverAsync(f,
|
||||
new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("W", Host, "N7:5", AbLegacyDataType.Int));
|
||||
var before = drv.GetHealth().LastSuccessfulRead;
|
||||
f.Make = p => new FakeAbLegacyTag(p) { WriteStatusOverride = writeStatus };
|
||||
await Task.Delay(5);
|
||||
|
||||
var w = await drv.WriteAsync([new WriteRequest("W", 3)], CancellationToken.None);
|
||||
|
||||
w[0].StatusCode.ShouldBe(writeStatus == 0 ? AbLegacyStatusMapper.Good : AbLegacyStatusMapper.BadNotWritable);
|
||||
f.Tags["N7:5"].WriteCount.ShouldBe(1); // positive control: the write reached the device
|
||||
var h = drv.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Healthy);
|
||||
h.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_no_reply_write_status_degrades_like_the_exception_path_does()
|
||||
{
|
||||
var f = new SwitchableFactory { Make = p => new FakeAbLegacyTag(p) { WriteStatusOverride = (int)AbStatus.Timeout } };
|
||||
var drv = NewDriver(f, new AbLegacyTagDefinition("W", Host, "N7:5", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var w = await drv.WriteAsync([new WriteRequest("W", 3)], CancellationToken.None);
|
||||
|
||||
w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTimeout);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task One_write_call_is_worst_wins_but_still_advances_LastSuccessfulRead()
|
||||
{
|
||||
var f = new SwitchableFactory
|
||||
{
|
||||
Make = p => p.TagName == "N7:6" ? Dead(p) : new FakeAbLegacyTag(p) { Value = 1 },
|
||||
};
|
||||
var drv = NewDriver(f,
|
||||
new AbLegacyTagDefinition("Good", Host, "N7:5", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("Bad", Host, "N7:6", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var before = drv.GetHealth().LastSuccessfulRead;
|
||||
await Task.Delay(5);
|
||||
|
||||
var w = await drv.WriteAsync([new WriteRequest("Good", 1), new WriteRequest("Bad", 2)], CancellationToken.None);
|
||||
|
||||
w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
w[1].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
var h = drv.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Degraded);
|
||||
h.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_write_inside_the_backoff_window_fails_fast_even_with_a_cached_handle()
|
||||
{
|
||||
var now = new DateTime(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
|
||||
var f = new SwitchableFactory();
|
||||
var drv = NewDriver(f,
|
||||
new AbLegacyTagDefinition("W", Host, "N7:5", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("D1", Host, "N7:6", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("D2", Host, "N7:7", AbLegacyDataType.Int));
|
||||
drv.Clock = () => now;
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await drv.ReadAsync(["W"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // cache W
|
||||
var cached = f.Tags["N7:5"];
|
||||
f.Make = Dead;
|
||||
await drv.ReadAsync(["D1", "D2"], CancellationToken.None); // two consecutive no-reply faults: device down
|
||||
drv.GetDeviceState(Host)!.IsThrottled(now).ShouldBeTrue();
|
||||
|
||||
var w = await drv.WriteAsync([new WriteRequest("W", 9)], CancellationToken.None);
|
||||
|
||||
w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
cached.WriteCount.ShouldBe(0);
|
||||
|
||||
// Positive control: once the window has passed, the same cached handle is written.
|
||||
now = now.AddSeconds(2);
|
||||
(await drv.WriteAsync([new WriteRequest("W", 9)], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
cached.WriteCount.ShouldBe(1);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_indirect_bit_write_releases_the_previous_parent_word_handle()
|
||||
{
|
||||
var f = new SwitchableFactory { Make = p => new FakeAbLegacyTag(p) { Value = p.TagName == "N7:0" ? (short)5 : (short)0 } };
|
||||
var drv = NewDriver(f, new AbLegacyTagDefinition("IndBit", Host, "N7:[N7:0]/3", AbLegacyDataType.Bit));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
(await drv.WriteAsync([new WriteRequest("IndBit", true)], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
var state = drv.GetDeviceState(Host)!;
|
||||
state.ParentRuntimes.ContainsKey("N7:5").ShouldBeTrue(); // positive control: the parent word was RMW'd
|
||||
f.Tags["N7:0"].Value = (short)6;
|
||||
(await drv.WriteAsync([new WriteRequest("IndBit", true)], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
|
||||
state.ParentRuntimes.ContainsKey("N7:5").ShouldBeFalse();
|
||||
state.ParentRuntimes.ContainsKey("N7:6").ShouldBeTrue();
|
||||
f.Tags["N7:5"].Disposed.ShouldBeTrue();
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>A probe read that parks ignoring cancellation, so ShutdownAsync is still joining.</summary>
|
||||
private sealed class ParkingTag(AbLegacyTagCreateParams p, Task release, TaskCompletionSource entered) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public override async Task ReadAsync(CancellationToken ct)
|
||||
{
|
||||
entered.TrySetResult();
|
||||
await release.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_slow_teardown_does_not_overwrite_a_newer_initialise_health()
|
||||
{
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var parked = false;
|
||||
var f = new SwitchableFactory
|
||||
{
|
||||
Make = p =>
|
||||
{
|
||||
if (parked) return new FakeAbLegacyTag(p) { Value = 1 };
|
||||
parked = true;
|
||||
return new ParkingTag(p, release.Task, entered);
|
||||
},
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20) },
|
||||
}, "drv-m1", f);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await entered.Task;
|
||||
|
||||
var shutdown = drv.ShutdownAsync(CancellationToken.None); // parks joining the wedged probe loop
|
||||
await Task.Delay(50);
|
||||
shutdown.IsCompleted.ShouldBeFalse();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None); // a newer initialise wins
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
release.SetResult();
|
||||
await shutdown;
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
// Positive control: an uncontested shutdown still writes Unknown.
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
private sealed class DisposeAwareTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public override Task ReadAsync(CancellationToken ct) =>
|
||||
Disposed ? throw new ObjectDisposedException("tag") : base.ReadAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 r0 M3: a reader that fetched a handle and then waited on its gate while another path evicted and
|
||||
/// disposed it used to read the disposed instance — a spurious comms fault and Degraded. It now re-resolves
|
||||
/// the handle once the gate is held.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_handle_evicted_while_a_reader_waited_for_its_gate_is_re_resolved()
|
||||
{
|
||||
var f = new SwitchableFactory { Make = p => new DisposeAwareTag(p) { Value = 4 } };
|
||||
var drv = NewDriver(f, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
var state = drv.GetDeviceState(Host)!;
|
||||
var first = f.Tags["N7:0"];
|
||||
|
||||
var gate = state.GetRuntimeLock("X");
|
||||
await gate.WaitAsync();
|
||||
// Signalled at exactly "handle fetched, about to wait on the gate" — no sleep, so the eviction below always
|
||||
// lands between the reader's fetch and its use (review 07 r1 test nit: the sleep could pass vacuously).
|
||||
var atGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
state.BeforeOperationGate = key => { if (key == "X") atGate.TrySetResult(); };
|
||||
var read = drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await atGate.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
state.Runtimes.TryRemove("X", out var evicted).ShouldBeTrue();
|
||||
evicted!.Dispose();
|
||||
gate.Release();
|
||||
var r = await read;
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
f.Tags["N7:0"].ShouldNotBeSameAs(first); // a fresh handle served it
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 r1 N3: a reader parked on a resolved INDIRECT key's gate while the index sweep evicted that key and
|
||||
/// forgot its gate used to RECONNECT it under the lock — resurrecting a stale resolution under a gate nobody
|
||||
/// else would get. It now answers that one call neutrally and leaves the key gone.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_swept_indirect_resolution_is_never_resurrected_under_a_forgotten_gate()
|
||||
{
|
||||
var creates = new System.Collections.Concurrent.ConcurrentQueue<string>();
|
||||
var f = new SwitchableFactory
|
||||
{
|
||||
Make = p =>
|
||||
{
|
||||
creates.Enqueue(p.TagName);
|
||||
return new FakeAbLegacyTag(p) { Value = p.TagName == "N7:0" ? (short)5 : (short)42 };
|
||||
},
|
||||
};
|
||||
var drv = NewDriver(f, new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await drv.ReadAsync(["Ind"], CancellationToken.None))[0].Value.ShouldBe(42); // positive control
|
||||
var state = drv.GetDeviceState(Host)!;
|
||||
const string key = "Ind=>N7:5";
|
||||
state.Runtimes.ContainsKey(key).ShouldBeTrue();
|
||||
|
||||
var gate = state.GetRuntimeLock(key);
|
||||
await gate.WaitAsync();
|
||||
var atGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
state.BeforeOperationGate = k => { if (k == key) atGate.TrySetResult(); };
|
||||
var read = drv.ReadAsync(["Ind"], CancellationToken.None);
|
||||
await atGate.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
// What the sweep does when the index moves: evict the resolution, then forget its gate.
|
||||
state.Runtimes.TryRemove(key, out var swept).ShouldBeTrue();
|
||||
swept!.Dispose();
|
||||
state.ForgetGates(key);
|
||||
gate.Release();
|
||||
var r = await read;
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
state.Runtimes.ContainsKey(key).ShouldBeFalse(); // not resurrected
|
||||
creates.Count(n => n == "N7:5").ShouldBe(1); // no reconnect of the stale resolution
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy); // neutral: no health evidence either way
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Review 07 r1 N4: an index read answered Unsupported is neutral, not a transport fault.</summary>
|
||||
[Fact]
|
||||
public async Task An_index_read_answered_Unsupported_is_neutral()
|
||||
{
|
||||
var f = new SwitchableFactory
|
||||
{
|
||||
Make = p => p.TagName == "N7:0"
|
||||
? new FakeAbLegacyTag(p) { Status = (int)AbStatus.Unsupported }
|
||||
: new FakeAbLegacyTag(p) { Value = 1 },
|
||||
};
|
||||
var drv = NewDriver(f,
|
||||
new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("Plain", Host, "N9:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["Ind", "Plain"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
drv.GetDeviceState(Host)!.ParentRuntimes.ContainsKey("N7:0").ShouldBeTrue(); // handle kept, not evicted
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Review 07 r1 N2: a persisted mismatched array READS BadTypeMismatch instead of garbage as Good.</summary>
|
||||
[Fact]
|
||||
public async Task A_mismatched_array_reads_BadTypeMismatch_not_garbage()
|
||||
{
|
||||
var f = new SwitchableFactory { Make = p => new FakeAbLegacyTag(p) { ArrayValue = new[] { 1f, 2f } } };
|
||||
var drv = NewDriver(f,
|
||||
new AbLegacyTagDefinition("Bad", Host, "F8:0", AbLegacyDataType.Int, ArrayLength: 2),
|
||||
new AbLegacyTagDefinition("Good", Host, "F9:0", AbLegacyDataType.Float, ArrayLength: 2));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
|
||||
f.Tags.ContainsKey("F8:0").ShouldBeFalse(); // nothing read at the wrong width
|
||||
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control
|
||||
r[1].Value.ShouldBe(new[] { 1f, 2f });
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
+28
@@ -128,4 +128,32 @@ public sealed class AbLegacyIndirectAddressingTests
|
||||
w.StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
|
||||
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 L7: every index value an indirect tag sweeps through used to leave one per-runtime semaphore and
|
||||
/// one creation semaphore behind until shutdown. They are pruned with the stale resolution's handle, so the
|
||||
/// per-key gate tables stay bounded by the authored set however far the index sweeps.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Sweeping_index_does_not_accumulate_per_key_gates()
|
||||
{
|
||||
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
|
||||
Seed(f, ("N7:0", (short)10));
|
||||
(await drv.ReadAsync(["Ind"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
var state = drv.GetDeviceState(Host)!;
|
||||
var baseline = state.GateCount;
|
||||
|
||||
for (short i = 11; i < 60; i++)
|
||||
{
|
||||
f.Tags["N7:0"].Value = i;
|
||||
f.Customise = p => new FakeAbLegacyTag(p) { Value = (short)(p.TagName == "N7:0" ? i : 1000 + i) };
|
||||
var r = (await drv.ReadAsync(["Ind"], CancellationToken.None)).Single();
|
||||
r.StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control: every resolution really read
|
||||
r.Value.ShouldBe((short)(1000 + i));
|
||||
}
|
||||
|
||||
state.GateCount.ShouldBe(baseline);
|
||||
// Only the live resolution's handle (plus the index word's parent runtime) remains cached.
|
||||
state.Runtimes.Keys.Count(k => k.StartsWith("Ind=>", StringComparison.Ordinal)).ShouldBe(1);
|
||||
}
|
||||
}
|
||||
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 H13 + the Lane A runtime contract: the device and tag tables are build-and-swapped (never cleared
|
||||
/// in place under a reader), <c>Dispose</c> may run concurrently with a still-running <c>ShutdownAsync</c>, and
|
||||
/// an abandoned <c>InitializeAsync</c> that overlaps the next <c>ShutdownAsync</c> must not resurrect state.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyLifecycleConcurrencyTests
|
||||
{
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
|
||||
/// <summary>A probe tag whose read parks until released, ignoring cancellation (a wedged device call).</summary>
|
||||
private sealed class ParkingProbeTag(AbLegacyTagCreateParams p, Task release) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public readonly TaskCompletionSource Entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public override async Task ReadAsync(CancellationToken ct)
|
||||
{
|
||||
Entered.TrySetResult();
|
||||
await release.ConfigureAwait(false);
|
||||
await base.ReadAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, string what)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow > deadline) throw new TimeoutException(what);
|
||||
await Task.Delay(10);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-initialising in a loop while other threads read the tables never throws "Collection was modified":
|
||||
/// the readers see either the old or the new table, never one being cleared.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reinit_under_concurrent_readers_never_throws()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host), new AbLegacyDeviceOptions("ab://10.0.0.6/1,0")],
|
||||
RawTags = AbLegacyRawTags.Entries(
|
||||
new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("Y", "ab://10.0.0.6/1,0", "N7:1", AbLegacyDataType.Int)),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-swap", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
using var stop = new CancellationTokenSource();
|
||||
var failures = new System.Collections.Concurrent.ConcurrentQueue<Exception>();
|
||||
var reads = 0;
|
||||
var readers = Enumerable.Range(0, 3).Select(n => Task.Run(async () =>
|
||||
{
|
||||
while (!stop.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = drv.GetHostStatuses();
|
||||
_ = drv.ResolveHost("X");
|
||||
_ = await drv.ReadAsync(["X", "Y"], CancellationToken.None);
|
||||
Interlocked.Increment(ref reads);
|
||||
}
|
||||
catch (Exception ex) { failures.Enqueue(ex); }
|
||||
}
|
||||
})).ToArray();
|
||||
|
||||
// Keep re-initialising until the readers have demonstrably overlapped many swaps.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
for (var i = 0; i < 100 || Volatile.Read(ref reads) < 200; i++)
|
||||
{
|
||||
if (DateTime.UtcNow > deadline) break;
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
}
|
||||
stop.Cancel();
|
||||
await Task.WhenAll(readers);
|
||||
|
||||
failures.ShouldBeEmpty();
|
||||
reads.ShouldBeGreaterThan(0); // positive control: the readers really ran against the swapping tables
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>A re-init publishes a NEW device state; the old one is retired, not mutated in place.</summary>
|
||||
[Fact]
|
||||
public async Task Reinit_publishes_a_new_table_and_retires_the_old_state()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-swap", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
var old = drv.GetDeviceState(Host)!;
|
||||
var oldTag = factory.Tags["N7:0"];
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var fresh = drv.GetDeviceState(Host)!;
|
||||
fresh.ShouldNotBeSameAs(old);
|
||||
old.IsRetired.ShouldBeTrue();
|
||||
oldTag.Disposed.ShouldBeTrue();
|
||||
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A runtime created by a read that was already in flight when shutdown retired its device state is disposed
|
||||
/// instead of being cached on the retired state (where nothing would ever release it).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Runtime_created_after_retirement_is_disposed_not_cached()
|
||||
{
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
FakeAbLegacyTag? created = null;
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => created = new InitParkingTag(p, entered, release.Task) { Value = 1 },
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-late", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var state = drv.GetDeviceState(Host)!;
|
||||
|
||||
var read = drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await entered.Task;
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
release.SetResult();
|
||||
_ = await read;
|
||||
|
||||
created.ShouldNotBeNull();
|
||||
created!.Disposed.ShouldBeTrue();
|
||||
state.Runtimes.ShouldBeEmpty();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown); // the late read did not overwrite shutdown's health
|
||||
}
|
||||
|
||||
private sealed class InitParkingTag(AbLegacyTagCreateParams p, TaskCompletionSource entered, Task release)
|
||||
: FakeAbLegacyTag(p)
|
||||
{
|
||||
public override async Task InitializeAsync(CancellationToken ct)
|
||||
{
|
||||
entered.TrySetResult();
|
||||
await release.ConfigureAwait(false);
|
||||
await base.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lane A's final stop calls ShutdownAsync and then DisposeAsync/Dispose; under the 5 s bound Dispose can run
|
||||
/// while ShutdownAsync is still joining a wedged probe loop. Both complete without throwing and every runtime
|
||||
/// is released.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Dispose_concurrent_with_running_shutdown_is_safe()
|
||||
{
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
ParkingProbeTag? probe = null;
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => p.TagName == "S:0"
|
||||
? probe = new ParkingProbeTag(p, release.Task)
|
||||
: new FakeAbLegacyTag(p) { Value = 1 },
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20) },
|
||||
}, "drv-dispose", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
await WaitUntilAsync(() => probe is not null, "probe never created");
|
||||
await probe!.Entered.Task;
|
||||
|
||||
var shutdown = drv.ShutdownAsync(CancellationToken.None); // parks joining the wedged probe loop
|
||||
await Task.Delay(50);
|
||||
shutdown.IsCompleted.ShouldBeFalse();
|
||||
|
||||
Should.NotThrow(() => drv.Dispose());
|
||||
await Should.NotThrowAsync(async () => await drv.DisposeAsync());
|
||||
release.SetResult();
|
||||
await shutdown;
|
||||
|
||||
factory.Tags["N7:0"].Disposed.ShouldBeTrue();
|
||||
probe.Disposed.ShouldBeTrue();
|
||||
drv.DeviceCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An InitializeAsync the runtime abandoned (it is parked releasing a wedged prior probe loop) overlaps the next
|
||||
/// ShutdownAsync. When it resumes it must NOT publish its tables or start probe loops — that state would sit
|
||||
/// behind a driver the runtime believes is stopped. A later Initialize publishes normally (positive control).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Abandoned_initialize_overlapping_shutdown_does_not_resurrect_state()
|
||||
{
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var probes = new System.Collections.Concurrent.ConcurrentQueue<FakeAbLegacyTag>();
|
||||
ParkingProbeTag? first = null;
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p =>
|
||||
{
|
||||
FakeAbLegacyTag t = first is null ? first = new ParkingProbeTag(p, release.Task) : new FakeAbLegacyTag(p);
|
||||
probes.Enqueue(t);
|
||||
return t;
|
||||
},
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20) },
|
||||
}, "drv-abandon", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitUntilAsync(() => first is not null, "probe never created");
|
||||
await first!.Entered.Task;
|
||||
|
||||
var abandoned = drv.InitializeAsync("{}", CancellationToken.None); // parks joining the wedged loop
|
||||
await Task.Delay(50);
|
||||
abandoned.IsCompleted.ShouldBeFalse();
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
release.SetResult();
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => abandoned);
|
||||
var createdAfter = probes.Count;
|
||||
await Task.Delay(150); // several probe intervals
|
||||
|
||||
drv.DeviceCount.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
probes.Count.ShouldBe(createdAfter);
|
||||
|
||||
// Positive control: a fresh Initialize after the stop publishes and probes as normal.
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
drv.DeviceCount.ShouldBe(1);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
await WaitUntilAsync(() => probes.Count > createdAfter, "fresh init never probed");
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 P7: adjacent N/B/F/L elements of one device were never coalesced (one PCCC round trip per tag),
|
||||
/// although the array path proves the transport can read a span. Scalar tags near each other on one file now
|
||||
/// ride ONE multi-element read and are decoded from their offsets — with exactly the CLR types the single-tag
|
||||
/// path produces — and a block the device refuses falls back to per-tag reads.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyReadCoalescingTests
|
||||
{
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
|
||||
private static (AbLegacyDriver Drv, FakeAbLegacyTagFactory Factory, List<AbLegacyTagCreateParams> Created) NewDriver(
|
||||
Func<AbLegacyTagCreateParams, FakeAbLegacyTag> make, bool coalesce, params AbLegacyTagDefinition[] tags)
|
||||
{
|
||||
var created = new List<AbLegacyTagCreateParams>();
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p =>
|
||||
{
|
||||
lock (created) created.Add(p);
|
||||
return make(p);
|
||||
},
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags = AbLegacyRawTags.Entries(tags),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
CoalesceReads = coalesce,
|
||||
}, "drv-coalesce", factory);
|
||||
return (drv, factory, created);
|
||||
}
|
||||
|
||||
private static AbLegacyTagDefinition Tag(string name, string address, AbLegacyDataType type) =>
|
||||
new(name, Host, address, type);
|
||||
|
||||
[Fact]
|
||||
public async Task Nearby_N_file_words_and_bits_ride_one_read_and_decode_like_single_reads()
|
||||
{
|
||||
var (drv, _, created) = NewDriver(
|
||||
p => new FakeAbLegacyTag(p) { ArrayValue = new short[] { 10, 11, 12, 4, 14, -15 } },
|
||||
coalesce: true,
|
||||
Tag("W0", "N7:0", AbLegacyDataType.Int),
|
||||
Tag("W1", "N7:1", AbLegacyDataType.AnalogInt),
|
||||
Tag("B32", "N7:3/2", AbLegacyDataType.Bit),
|
||||
Tag("B31", "N7:3/1", AbLegacyDataType.Bit),
|
||||
Tag("W5", "N7:5", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["W0", "W1", "B32", "B31", "W5"], CancellationToken.None);
|
||||
|
||||
r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
|
||||
r[0].Value.ShouldBe(10); // int, exactly as DecodeValue(Int) returns
|
||||
r[1].Value.ShouldBe(11);
|
||||
r[2].Value.ShouldBe(true); // word 3 = 0b100: bit 2 set
|
||||
r[3].Value.ShouldBe(false);
|
||||
r[4].Value.ShouldBe(-15);
|
||||
created.Count.ShouldBe(1);
|
||||
created[0].TagName.ShouldBe("N7:0");
|
||||
created[0].ElementCount.ShouldBe(6);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Float_and_long_files_coalesce_at_their_element_width()
|
||||
{
|
||||
var (drv, _, created) = NewDriver(
|
||||
p => p.TagName.StartsWith('F')
|
||||
? new FakeAbLegacyTag(p) { ArrayValue = new[] { 1.5f, 0f, 2.5f } }
|
||||
: new FakeAbLegacyTag(p) { ArrayValue = new[] { 7, int.MinValue } },
|
||||
coalesce: true,
|
||||
Tag("F0", "F8:0", AbLegacyDataType.Float),
|
||||
Tag("F2", "F8:2", AbLegacyDataType.Float),
|
||||
Tag("L0", "L9:0", AbLegacyDataType.Long),
|
||||
Tag("L1b31", "L9:1/31", AbLegacyDataType.Bit));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["F0", "F2", "L0", "L1b31"], CancellationToken.None);
|
||||
|
||||
r.Select(s => s.Value).ShouldBe(new object[] { 1.5f, 2.5f, 7, true });
|
||||
created.Select(c => (c.TagName, c.ElementCount)).OrderBy(x => x.TagName)
|
||||
.ShouldBe(new[] { ("F8:0", 3), ("L9:0", 2) });
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Far_apart_or_ineligible_tags_keep_the_single_tag_path()
|
||||
{
|
||||
var (drv, _, created) = NewDriver(
|
||||
p => new FakeAbLegacyTag(p) { Value = 1 },
|
||||
coalesce: true,
|
||||
Tag("Near", "N7:0", AbLegacyDataType.Int),
|
||||
Tag("Far", "N7:40", AbLegacyDataType.Int), // gap > MaxCoalesceGap
|
||||
Tag("Mistyped", "N7:1", AbLegacyDataType.Float), // width mismatch: single path answers as before
|
||||
Tag("Acc", "T4:0.ACC", AbLegacyDataType.Int), // sub-element
|
||||
Tag("Status", "S:1", AbLegacyDataType.Int), // no file number
|
||||
Tag("Ind", "N7:[N7:0]", AbLegacyDataType.Int)); // indirect
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
_ = await drv.ReadAsync(["Near", "Far", "Mistyped", "Acc", "Status", "Ind"], CancellationToken.None);
|
||||
|
||||
created.ShouldAllBe(c => c.ElementCount == 1);
|
||||
created.Select(c => c.TagName).ShouldContain("N7:40");
|
||||
created.Select(c => c.TagName).ShouldContain("T4:0.ACC");
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_block_falls_back_to_single_reads_and_is_remembered_after_a_repeat()
|
||||
{
|
||||
// N7:9 is past the end of the file: the BLOCK is refused; singly, only that tag is.
|
||||
var (drv, _, created) = NewDriver(
|
||||
p => p.ElementCount > 1 || p.TagName == "N7:9"
|
||||
? new FakeAbLegacyTag(p) { Status = (int)AbStatus.OutOfBounds }
|
||||
: new FakeAbLegacyTag(p) { Value = 3 },
|
||||
coalesce: true,
|
||||
Tag("A", "N7:0", AbLegacyDataType.Int),
|
||||
Tag("B", "N7:2", AbLegacyDataType.Int),
|
||||
Tag("C", "N7:9", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
for (var poll = 0; poll < 3; poll++)
|
||||
{
|
||||
var r = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
|
||||
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
|
||||
r[2].StatusCode.ShouldBe(AbLegacyStatusMapper.MapStatus(AbStatus.OutOfBounds));
|
||||
}
|
||||
// Refused on polls 0 and 1 (each falling back to singles in the same pass); remembered after the repeat,
|
||||
// so poll 2 does not try the block again.
|
||||
created.Count(c => c.ElementCount > 1).ShouldBe(2);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy); // refusals are not comms faults
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_block_comms_fault_fails_its_members_together_and_degrades()
|
||||
{
|
||||
var (drv, _, _) = NewDriver(
|
||||
p => new FakeAbLegacyTag(p) { ThrowOnRead = true, Exception = new System.Net.Sockets.SocketException(10054) },
|
||||
coalesce: true,
|
||||
Tag("A", "N7:0", AbLegacyDataType.Int),
|
||||
Tag("B", "N7:1", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
||||
|
||||
r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CoalesceReads_false_reads_every_tag_singly()
|
||||
{
|
||||
var (drv, _, created) = NewDriver(
|
||||
p => new FakeAbLegacyTag(p) { Value = 1 },
|
||||
coalesce: false,
|
||||
Tag("A", "N7:0", AbLegacyDataType.Int),
|
||||
Tag("B", "N7:1", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
||||
|
||||
r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
|
||||
created.Count.ShouldBe(2);
|
||||
created.ShouldAllBe(c => c.ElementCount == 1);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", true)]
|
||||
[InlineData("""{"coalesceReads":false,"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", false)]
|
||||
[InlineData("""{"coalesceReads":true,"someFutureKey":1,"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", true)]
|
||||
public void ParseOptions_reads_coalesceReads_defaulting_true(string json, bool expected) =>
|
||||
(AbLegacyDriverFactoryExtensions.ParseOptions("drv", json).CoalesceReads != false).ShouldBe(expected);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>Thread-safe capturing logger shared by the remediation tests (poll loops log from pool threads).</summary>
|
||||
internal sealed class AbLegacyTestLogger : ILogger<AbLegacyDriver>
|
||||
{
|
||||
/// <summary>Every entry logged, in order.</summary>
|
||||
public System.Collections.Concurrent.ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new();
|
||||
|
||||
/// <summary>The Warning-level messages logged.</summary>
|
||||
public IReadOnlyList<string> Warnings => [.. Entries.Where(e => e.Level == LogLevel.Warning).Select(e => e.Message)];
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter) => Entries.Enqueue((logLevel, formatter(state, exception)));
|
||||
}
|
||||
@@ -109,6 +109,11 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
|
||||
/// <param name="value">The value to encode.</param>
|
||||
public virtual void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => Value = value;
|
||||
|
||||
/// <summary>Encodes a whole typed array (array writes); stored as <see cref="ArrayValue"/>.</summary>
|
||||
/// <param name="type">The AbLegacy element type.</param>
|
||||
/// <param name="values">The typed elements.</param>
|
||||
public virtual void EncodeArray(AbLegacyDataType type, Array values) => ArrayValue = values;
|
||||
|
||||
/// <summary>Disposes the tag.</summary>
|
||||
public virtual void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The Calculation driver's run-guard options: the node-wide <c>Scripting</c> section is the base, and a key
|
||||
/// authored in the driver's own config overrides only that knob. Before the section was bound, a Calculation
|
||||
/// driver always ran on the compiled defaults whatever the node configured.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CalculationScriptingOptionsResolutionTests
|
||||
{
|
||||
private static readonly ScriptingOptions Node = new()
|
||||
{
|
||||
MaxAllocBytesPerRun = 8L * 1024 * 1024,
|
||||
QuarantineAfterConsecutiveTimeouts = 7,
|
||||
StuckOrphanQuarantineFactor = 4,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void No_driver_keys_and_no_node_options_resolves_null_so_the_guard_uses_its_defaults() =>
|
||||
CalculationDriverFactoryExtensions.ResolveScripting(null, null, null, out _).ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public void No_driver_keys_inherits_every_node_knob()
|
||||
{
|
||||
var resolved = CalculationDriverFactoryExtensions.ResolveScripting(null, null, Node, out _).ShouldNotBeNull();
|
||||
|
||||
resolved.MaxAllocBytesPerRun.ShouldBe(Node.MaxAllocBytesPerRun);
|
||||
resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(7);
|
||||
resolved.StuckOrphanQuarantineFactor.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_driver_key_overrides_only_its_own_knob_and_the_node_object_is_not_mutated()
|
||||
{
|
||||
var resolved = CalculationDriverFactoryExtensions.ResolveScripting(null, 2, Node, out var adjustments).ShouldNotBeNull();
|
||||
|
||||
resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(2);
|
||||
resolved.MaxAllocBytesPerRun.ShouldBe(Node.MaxAllocBytesPerRun, "the unauthored key keeps the node value");
|
||||
resolved.StuckOrphanQuarantineFactor.ShouldBe(4, "the stuck factor has no driver key; it is node-wide");
|
||||
resolved.ShouldNotBeSameAs(Node);
|
||||
Node.QuarantineAfterConsecutiveTimeouts.ShouldBe(7);
|
||||
adjustments.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Review m-2: an out-of-range value authored in the driver's own config is clamped AND reported, so
|
||||
/// the factory can log it rather than the guard changing it silently.</summary>
|
||||
[Fact]
|
||||
public void An_out_of_range_driver_key_is_clamped_and_reported()
|
||||
{
|
||||
var resolved = CalculationDriverFactoryExtensions.ResolveScripting(1024L, 0, Node, out var adjustments).ShouldNotBeNull();
|
||||
|
||||
resolved.MaxAllocBytesPerRun.ShouldBe(ScriptingOptions.MinMaxAllocBytesPerRun);
|
||||
resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(1);
|
||||
adjustments.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_driver_key_with_no_node_options_layers_on_the_compiled_defaults()
|
||||
{
|
||||
var resolved = CalculationDriverFactoryExtensions.ResolveScripting(0L, null, null, out _).ShouldNotBeNull();
|
||||
|
||||
resolved.MaxAllocBytesPerRun.ShouldBe(0);
|
||||
resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(ScriptingOptions.DefaultQuarantineAfterConsecutiveTimeouts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// An in-test TCP forwarder in front of the S7 fixture that can be FROZEN: while frozen it keeps
|
||||
/// every socket open but forwards nothing in either direction — the frozen-but-established peer
|
||||
/// (cable pull, firewall DROP) that only a wire-op deadline can detect, produced without touching
|
||||
/// the shared fixture. It counts the ISO-on-TCP (TPKT) request frames the client sends while frozen,
|
||||
/// so a test can assert how many wire operations went out without timing anything.
|
||||
/// </summary>
|
||||
internal sealed class FreezableTcpProxy : IAsyncDisposable
|
||||
{
|
||||
private readonly string _targetHost;
|
||||
private readonly int _targetPort;
|
||||
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly List<TcpClient> _sockets = [];
|
||||
private volatile bool _frozen;
|
||||
private int _framesWhileFrozen;
|
||||
|
||||
/// <summary>Starts a proxy forwarding to <paramref name="targetHost"/>:<paramref name="targetPort"/>.</summary>
|
||||
/// <param name="targetHost">The fixture host.</param>
|
||||
/// <param name="targetPort">The fixture port.</param>
|
||||
public FreezableTcpProxy(string targetHost, int targetPort)
|
||||
{
|
||||
_targetHost = targetHost;
|
||||
_targetPort = targetPort;
|
||||
_listener.Start();
|
||||
_ = AcceptLoopAsync();
|
||||
}
|
||||
|
||||
/// <summary>The local port clients connect to.</summary>
|
||||
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||||
|
||||
/// <summary>TPKT request frames the client sent while the proxy was frozen.</summary>
|
||||
public int FramesWhileFrozen => Volatile.Read(ref _framesWhileFrozen);
|
||||
|
||||
/// <summary>Stops forwarding (sockets stay open).</summary>
|
||||
public void Freeze() => _frozen = true;
|
||||
|
||||
/// <summary>Resumes forwarding for connections made from now on.</summary>
|
||||
public void Unfreeze() => _frozen = false;
|
||||
|
||||
private async Task AcceptLoopAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
var client = await _listener.AcceptTcpClientAsync(_cts.Token).ConfigureAwait(false);
|
||||
var upstream = new TcpClient();
|
||||
await upstream.ConnectAsync(_targetHost, _targetPort, _cts.Token).ConfigureAwait(false);
|
||||
lock (_sockets) { _sockets.Add(client); _sockets.Add(upstream); }
|
||||
// A connection accepted while frozen stays frozen for its whole life (its peer is "gone").
|
||||
var frozenAtBirth = _frozen;
|
||||
_ = PumpAsync(client.GetStream(), upstream.GetStream(), countFrames: true, frozenAtBirth);
|
||||
_ = PumpAsync(upstream.GetStream(), client.GetStream(), countFrames: false, frozenAtBirth);
|
||||
}
|
||||
}
|
||||
catch { /* disposed */ }
|
||||
}
|
||||
|
||||
private async Task PumpAsync(NetworkStream from, NetworkStream to, bool countFrames, bool frozenAtBirth)
|
||||
{
|
||||
var buffer = new byte[8192];
|
||||
var pending = new List<byte>();
|
||||
try
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
var n = await from.ReadAsync(buffer, _cts.Token).ConfigureAwait(false);
|
||||
if (n == 0) return;
|
||||
var frozen = _frozen || frozenAtBirth;
|
||||
if (countFrames && frozen)
|
||||
{
|
||||
// Count complete TPKT frames: 0x03 0x00 then a big-endian total length.
|
||||
pending.AddRange(buffer.AsSpan(0, n).ToArray());
|
||||
while (pending.Count >= 4)
|
||||
{
|
||||
var len = (pending[2] << 8) | pending[3];
|
||||
if (len < 4 || pending.Count < len) break;
|
||||
pending.RemoveRange(0, len);
|
||||
Interlocked.Increment(ref _framesWhileFrozen);
|
||||
}
|
||||
}
|
||||
if (frozen) continue; // swallow — the peer never answers
|
||||
await to.WriteAsync(buffer.AsMemory(0, n), _cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch { /* socket closed / disposed */ }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_listener.Stop();
|
||||
lock (_sockets) foreach (var s in _sockets) s.Dispose();
|
||||
_cts.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
||||
|
||||
/// <summary>
|
||||
/// Live gate for R07:P1 over real S7comm: a frozen-but-established peer (produced by an in-test
|
||||
/// <see cref="FreezableTcpProxy"/>, so the shared fixture is never touched). The first wire op of
|
||||
/// the pass blows its deadline and marks the connection dead; the rest of the pass must NOT go to
|
||||
/// the wire. The assertion counts request frames, not seconds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>What this gate does and does not prove.</b> Verified by mutation (2026-10-22): on the real
|
||||
/// stack this test also passes with the driver's dead-handle guard removed, because S7.Net 0.20
|
||||
/// honours the deadline's cancellation token and closes its own socket, so the next op fails at once
|
||||
/// with <c>ConnectionError</c> and sends nothing. The driver guard (proven by
|
||||
/// <c>S7DeadHandleShortCircuitTests</c> against a fake that does not self-close) is what covers an
|
||||
/// op that ignores its token — the <c>WaitAsync</c> leg of <c>S7OperationDeadline</c>, where the
|
||||
/// abandoned socket stays open and every further op would pay the full deadline. This test pins the
|
||||
/// end-to-end property (one request per frozen pass, then recovery) against S7.Net upgrades.
|
||||
/// </remarks>
|
||||
[Collection(Snap7ServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "S7_1500")]
|
||||
public sealed class S7_1500FrozenPeerTests(Snap7ServerFixture sim)
|
||||
{
|
||||
/// <summary>A pass against a frozen peer issues ONE request, then fails the rest fast; it recovers after.</summary>
|
||||
[Fact]
|
||||
public async Task A_frozen_peer_costs_one_wire_op_per_pass_not_one_per_tag()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await using var proxy = new FreezableTcpProxy(sim.Host, sim.Port);
|
||||
var baseOptions = S7_1500Profile.BuildOptions("127.0.0.1", proxy.Port);
|
||||
var options = new S7DriverOptions
|
||||
{
|
||||
Host = baseOptions.Host, Port = baseOptions.Port, CpuType = baseOptions.CpuType,
|
||||
Rack = baseOptions.Rack, Slot = baseOptions.Slot,
|
||||
Timeout = TimeSpan.FromSeconds(1), // the per-op deadline that detects the frozen peer
|
||||
Probe = baseOptions.Probe,
|
||||
SpanReadMaxGapBytes = -1, // one wire request per tag, so "one per tag" is countable
|
||||
RawTags = baseOptions.RawTags,
|
||||
};
|
||||
string[] refs =
|
||||
[
|
||||
S7_1500Profile.ProbeTag, S7_1500Profile.SmokeI16Tag, S7_1500Profile.SmokeI32Tag,
|
||||
S7_1500Profile.SmokeF32Tag, S7_1500Profile.SmokeBoolTag, S7_1500Profile.WriteScratchTag,
|
||||
];
|
||||
|
||||
await using var drv = new S7Driver(options, driverInstanceId: "s7-frozen-peer-live");
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
|
||||
// Positive control: through the proxy, every tag reads Good.
|
||||
(await drv.ReadAsync(refs, ct)).ShouldAllBe(s => s.StatusCode == 0u);
|
||||
|
||||
proxy.Freeze();
|
||||
var frozen = await drv.ReadAsync(refs, ct);
|
||||
frozen.ShouldAllBe(s => s.StatusCode != 0u);
|
||||
proxy.FramesWhileFrozen.ShouldBe(1, "only the request that discovered the dead connection may go out");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
// Recovery: the next pass reopens (a fresh proxy connection) and reads Good again.
|
||||
proxy.Unfreeze();
|
||||
var recovered = await drv.ReadAsync(refs, ct);
|
||||
recovered.ShouldAllBe(s => s.StatusCode == 0u);
|
||||
Convert.ToInt32(recovered[0].Value).ShouldBe(S7_1500Profile.ProbeSeedValue);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
||||
|
||||
/// <summary>
|
||||
/// Live gate for R07:H4 — a real CPU refusal over real S7comm. The snap7 server has no DB99, so it
|
||||
/// answers a read of <c>DB99.DBW0</c> with an S7 item error. That is a per-tag Bad with no health
|
||||
/// impact (and LastSuccessfulRead advances), not a Degraded that would trip the comms-loss watchdog
|
||||
/// against a reachable PLC.
|
||||
/// </summary>
|
||||
[Collection(Snap7ServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "S7_1500")]
|
||||
public sealed class S7_1500RefusalTests(Snap7ServerFixture sim)
|
||||
{
|
||||
/// <summary>A refused DB read is per-tag Bad, keeps the driver Healthy, and its neighbour reads Good.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_of_a_missing_DB_is_refused_per_tag_without_degrading()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var baseOptions = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
|
||||
var options = new S7DriverOptions
|
||||
{
|
||||
Host = baseOptions.Host, Port = baseOptions.Port, CpuType = baseOptions.CpuType,
|
||||
Rack = baseOptions.Rack, Slot = baseOptions.Slot, Timeout = baseOptions.Timeout,
|
||||
Probe = baseOptions.Probe,
|
||||
RawTags = [.. baseOptions.RawTags, .. S7RawTags.Entries(
|
||||
new S7TagDefinition("Missing/Db99", "DB99.DBW0", S7DataType.UInt16, Writable: false))],
|
||||
};
|
||||
await using var drv = new S7Driver(options, driverInstanceId: "s7-refusal-live");
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
var before = drv.GetHealth().LastSuccessfulRead!.Value;
|
||||
// Let the wall clock move past the init stamp so "advanced" is asserted strictly (review m-1).
|
||||
while (DateTime.UtcNow <= before) await Task.Yield();
|
||||
|
||||
// Read the refused tag ALONE first: an all-refused pass is exactly the case that used to
|
||||
// freeze LastSuccessfulRead and latch Degraded.
|
||||
var refused = await drv.ReadAsync(["Missing/Db99"], ct);
|
||||
refused[0].StatusCode.ShouldNotBe(0u);
|
||||
refused[0].Value.ShouldBeNull();
|
||||
var health = drv.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Healthy, "a CPU refusal is not a comms fault");
|
||||
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before, "the refusal is an answer: the stamp advances");
|
||||
|
||||
// Positive control: the same connection still serves the seeded neighbour.
|
||||
var good = await drv.ReadAsync([S7_1500Profile.ProbeTag], ct);
|
||||
good[0].StatusCode.ShouldBe(0u);
|
||||
Convert.ToInt32(good[0].Value).ShouldBe(S7_1500Profile.ProbeSeedValue);
|
||||
}
|
||||
|
||||
/// <summary>A refused write is per-tag Bad and keeps the driver Healthy; a good write still lands.</summary>
|
||||
[Fact]
|
||||
public async Task A_write_to_a_missing_DB_is_refused_per_tag_without_degrading()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var baseOptions = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
|
||||
var options = new S7DriverOptions
|
||||
{
|
||||
Host = baseOptions.Host, Port = baseOptions.Port, CpuType = baseOptions.CpuType,
|
||||
Rack = baseOptions.Rack, Slot = baseOptions.Slot, Timeout = baseOptions.Timeout,
|
||||
Probe = baseOptions.Probe,
|
||||
RawTags = [.. baseOptions.RawTags, .. S7RawTags.Entries(
|
||||
new S7TagDefinition("Missing/Db99W", "DB99.DBW0", S7DataType.UInt16, Writable: true))],
|
||||
};
|
||||
await using var drv = new S7Driver(options, driverInstanceId: "s7-refusal-write-live");
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
|
||||
var refused = await drv.WriteAsync([new WriteRequest("Missing/Db99W", (ushort)5)], ct);
|
||||
refused[0].StatusCode.ShouldNotBe(0u);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a CPU write refusal is not a comms fault");
|
||||
|
||||
// Positive control: a write the CPU accepts still lands (and reads back).
|
||||
var ok = await drv.WriteAsync([new WriteRequest(S7_1500Profile.WriteScratchTag, (ushort)4711)], ct);
|
||||
ok[0].StatusCode.ShouldBe(0u);
|
||||
var back = await drv.ReadAsync([S7_1500Profile.WriteScratchTag], ct);
|
||||
Convert.ToInt32(back[0].Value).ShouldBe(4711);
|
||||
await drv.WriteAsync([new WriteRequest(S7_1500Profile.WriteScratchTag, (ushort)0)], ct);
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
||||
|
||||
/// <summary>
|
||||
/// Live gate for R07:P2 over real S7comm. The fixture's M area is 256 bytes. <c>MW10</c> is seeded
|
||||
/// (4097); <c>MD254</c> runs two bytes past the end of the area. With the gap budget opened wide
|
||||
/// the planner merges them into ONE block <c>MB10..258</c>, which the CPU refuses — before the fix
|
||||
/// that made the perfectly readable <c>MW10</c> Bad (and the driver Degraded) on every poll.
|
||||
/// </summary>
|
||||
[Collection(Snap7ServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "S7_1500")]
|
||||
public sealed class S7_1500SpanRefusalTests(Snap7ServerFixture sim)
|
||||
{
|
||||
/// <summary>The good neighbour of an over-running member keeps reading its seeded value, every pass.</summary>
|
||||
[Fact]
|
||||
public async Task A_member_past_the_area_end_does_not_spoil_its_span_neighbour()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var options = new S7DriverOptions
|
||||
{
|
||||
Host = sim.Host, Port = sim.Port, CpuType = S7CpuType.S71500,
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
// Wide enough that MW10 and MD254 land in one planned span.
|
||||
SpanReadMaxGapBytes = 256,
|
||||
SpanReadMaxSpanBytes = 256,
|
||||
RawTags = S7RawTags.Entries(
|
||||
new S7TagDefinition("Mk/Word10", "MW10", S7DataType.UInt16, Writable: false),
|
||||
new S7TagDefinition("Mk/PastEnd", "MD254", S7DataType.Int32, Writable: false)),
|
||||
};
|
||||
await using var drv = new S7Driver(options, driverInstanceId: "s7-span-refusal-live");
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
|
||||
for (var pass = 0; pass < 3; pass++)
|
||||
{
|
||||
var r = await drv.ReadAsync(["Mk/Word10", "Mk/PastEnd"], ct);
|
||||
r[0].StatusCode.ShouldBe(0u, $"pass {pass}: the in-range neighbour must be served");
|
||||
Convert.ToInt32(r[0].Value).ShouldBe(4097);
|
||||
r[1].StatusCode.ShouldNotBe(0u, "the over-running member is refused on its own");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a refusal is not a comms fault");
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
||||
|
||||
/// <summary>
|
||||
/// Live gate for R07:H3 — one mis-authored tag must not darken the PLC. Before the fix each of
|
||||
/// these shapes threw at init, so the driver Faulted and served nothing (and the actor retried the
|
||||
/// same init every 10 s forever). Now each is skipped on its own and the good tags keep reading
|
||||
/// their seeded values over real S7comm.
|
||||
/// </summary>
|
||||
[Collection(Snap7ServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "S7_1500")]
|
||||
public sealed class S7_1500TagShapeSkipTests(Snap7ServerFixture sim)
|
||||
{
|
||||
/// <summary>Unservable tags are skipped per tag; the seeded neighbours still read Good.</summary>
|
||||
[Fact]
|
||||
public async Task Mis_authored_tags_are_skipped_and_the_rest_of_the_PLC_still_reads()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var good = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
|
||||
var bad = new[]
|
||||
{
|
||||
new S7TagDefinition("Bad/Typo", "DB1.DBZ0", S7DataType.Int16),
|
||||
new S7TagDefinition("Bad/WideOnWord", "DB1.DBW0", S7DataType.Float64),
|
||||
new S7TagDefinition("Bad/TimerAsInt", "T5", S7DataType.Int32, Writable: false),
|
||||
new S7TagDefinition("Bad/OffsetBoolArray", "DB1.DBX50.3", S7DataType.Bool, Writable: false, ArrayCount: 8),
|
||||
};
|
||||
var options = new S7DriverOptions
|
||||
{
|
||||
Host = good.Host, Port = good.Port, CpuType = good.CpuType, Rack = good.Rack, Slot = good.Slot,
|
||||
Timeout = good.Timeout, Probe = good.Probe,
|
||||
RawTags = [.. good.RawTags, .. S7RawTags.Entries(bad)],
|
||||
};
|
||||
|
||||
await using var drv = new S7Driver(options, driverInstanceId: "s7-shape-skip-live");
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
drv.GetSkippedTags().ShouldNotBeNull().Sample.Select(s => s.RawPath)
|
||||
.ShouldBe(bad.Select(b => b.Name), ignoreOrder: true);
|
||||
|
||||
var r = await drv.ReadAsync(
|
||||
[S7_1500Profile.ProbeTag, S7_1500Profile.SmokeI16Tag, S7_1500Profile.SmokeBoolTag, bad[0].Name], ct);
|
||||
r[0].StatusCode.ShouldBe(0u);
|
||||
Convert.ToInt32(r[0].Value).ShouldBe(S7_1500Profile.ProbeSeedValue);
|
||||
r[1].StatusCode.ShouldBe(0u);
|
||||
Convert.ToInt32(r[1].Value).ShouldBe((int)S7_1500Profile.SmokeI16SeedValue);
|
||||
r[2].StatusCode.ShouldBe(0u);
|
||||
Convert.ToBoolean(r[2].Value).ShouldBeTrue();
|
||||
r[3].StatusCode.ShouldBe(0x80340000u, "a skipped tag reads BadNodeIdUnknown");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>An <see cref="ILogger{T}"/> that records (level, message) pairs.</summary>
|
||||
internal sealed class CapturingS7Logger : ILogger<S7Driver>
|
||||
{
|
||||
private readonly List<(LogLevel Level, string Message)> _entries = [];
|
||||
|
||||
/// <summary>A snapshot of every entry logged so far.</summary>
|
||||
public IReadOnlyList<(LogLevel Level, string Message)> Entries
|
||||
{
|
||||
get { lock (_entries) return [.. _entries]; }
|
||||
}
|
||||
|
||||
/// <summary>The Warning messages logged so far.</summary>
|
||||
public IReadOnlyList<string> Warnings
|
||||
=> [.. Entries.Where(e => e.Level == LogLevel.Warning).Select(e => e.Message)];
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
lock (_entries) _entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
@@ -243,7 +243,9 @@ public sealed class S7ArrayWriteAndHealthTests
|
||||
var (drv, factory) = await TwoTagDriver();
|
||||
using var _ = drv;
|
||||
factory.Created[0].WriteFaults["DB1.DBW0"] = new PlcException(ErrorCode.WriteData, "Accessing object not allowed");
|
||||
factory.Created[0].WriteFaults["DB1.DBW2"] = new IOException("socket glitch");
|
||||
// A Degraded failure that is NOT connection-fatal: since R07:P1 a fatal one (an IOException)
|
||||
// would fail the rest of the batch without I/O, and C must reach the wire to be the success.
|
||||
factory.Created[0].WriteFaults["DB1.DBW2"] = new PlcException(ErrorCode.SendData, "device glitch");
|
||||
|
||||
var res = await drv.WriteAsync(
|
||||
[new WriteRequest("A", (ushort)1), new WriteRequest("B", (ushort)2), new WriteRequest("C", (ushort)3)],
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R07:P1 — once a pass has marked the connection dead (a wire op blew its deadline on a frozen
|
||||
/// peer), the rest of that pass must not go to the wire: each further op would pay the full
|
||||
/// timeout under the gate, so a 20-span pass took ~100 s and the comms-loss watchdog's clock only
|
||||
/// started after it. The remaining items are stamped <c>BadCommunicationError</c> without I/O.
|
||||
/// Every assertion counts wire operations; nothing is timed.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7DeadHandleShortCircuitTests
|
||||
{
|
||||
private const uint BadCommunicationError = 0x80050000u;
|
||||
|
||||
private static S7DriverOptions Options(int maxGap, params S7TagDefinition[] tags) => new()
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
SpanReadMaxGapBytes = maxGap,
|
||||
RawTags = S7RawTags.Entries(tags),
|
||||
};
|
||||
|
||||
/// <summary>Five isolated words — far enough apart that each is its own span.</summary>
|
||||
private static S7TagDefinition[] FarApartWords() =>
|
||||
[.. Enumerable.Range(0, 5).Select(i => new S7TagDefinition($"W{i}", $"DB{i + 1}.DBW0", S7DataType.UInt16, Writable: true))];
|
||||
|
||||
private static string[] Refs(int n) => [.. Enumerable.Range(0, n).Select(i => $"W{i}")];
|
||||
|
||||
private static async Task<(S7Driver, ScriptedS7PlcFactory)> StartAsync(S7DriverOptions options)
|
||||
{
|
||||
var factory = new ScriptedS7PlcFactory();
|
||||
var drv = new S7Driver(options, "s7-dead-short-circuit", factory);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
/// <summary>Per-tag path: the first timeout ends the pass's wire traffic.</summary>
|
||||
[Fact]
|
||||
public async Task Per_tag_pass_stops_issuing_wire_ops_once_the_handle_is_dead()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, factory) = await StartAsync(Options(maxGap: -1, FarApartWords()));
|
||||
using var _ = drv;
|
||||
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
|
||||
|
||||
var before = factory.WireOps;
|
||||
var r = await drv.ReadAsync(Refs(5), ct);
|
||||
|
||||
(factory.WireOps - before).ShouldBe(1, "only the op that discovered the dead handle may go to the wire");
|
||||
r.ShouldAllBe(s => s.StatusCode == BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
// Positive control: the next pass reopens and serves every tag.
|
||||
factory.Read = (_, _) => Task.FromResult<object?>((ushort)1);
|
||||
var recovered = await drv.ReadAsync(Refs(5), ct);
|
||||
recovered.ShouldAllBe(s => s.StatusCode == 0u);
|
||||
factory.Created.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Span path: arrays are block reads even alone, so five arrays are five spans.</summary>
|
||||
[Fact]
|
||||
public async Task Span_pass_stops_issuing_wire_ops_once_the_handle_is_dead()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
S7TagDefinition[] arrays =
|
||||
[.. Enumerable.Range(0, 5).Select(i => new S7TagDefinition($"W{i}", $"DB{i + 1}.DBW0", S7DataType.UInt16, Writable: false, ArrayCount: 2))];
|
||||
var (drv, factory) = await StartAsync(Options(maxGap: 16, arrays));
|
||||
using var _ = drv;
|
||||
factory.ReadBytes = (_, _, _, _, _) => throw new TimeoutException("frozen peer");
|
||||
|
||||
var before = factory.WireOps;
|
||||
var r = await drv.ReadAsync(Refs(5), ct);
|
||||
|
||||
(factory.WireOps - before).ShouldBe(1);
|
||||
r.ShouldAllBe(s => s.StatusCode == BadCommunicationError);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Multi-var path: a chunk that dies on a transport loss used to fall back to one per-tag read
|
||||
/// per member — each paying the timeout again. Now it marks the handle dead and the fallback is
|
||||
/// short-circuited.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Multi_var_transport_loss_does_not_fall_back_to_per_tag_wire_reads()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, factory) = await StartAsync(Options(maxGap: 0, FarApartWords()));
|
||||
using var _ = drv;
|
||||
factory.ReadMulti = (_, _) => throw new TimeoutException("frozen peer");
|
||||
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
|
||||
|
||||
var before = factory.WireOps;
|
||||
var r = await drv.ReadAsync(Refs(5), ct);
|
||||
|
||||
(factory.WireOps - before).ShouldBe(1);
|
||||
r.ShouldAllBe(s => s.StatusCode == BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Positive control for the classification: a multi-var REFUSAL (not a transport loss) still
|
||||
/// falls back per tag, so the members the CPU will serve are served.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Multi_var_refusal_still_falls_back_per_tag()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, factory) = await StartAsync(Options(maxGap: 0, FarApartWords()));
|
||||
using var _ = drv;
|
||||
// The live snap7 shape of a multi-var with one refused item.
|
||||
factory.ReadMulti = (_, _) => throw new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData,
|
||||
new global::S7.Net.PlcException(global::S7.Net.ErrorCode.WrongNumberReceivedBytes));
|
||||
factory.Read = (_, _) => Task.FromResult<object?>((ushort)9);
|
||||
|
||||
var before = factory.WireOps;
|
||||
var r = await drv.ReadAsync(Refs(5), ct);
|
||||
|
||||
(factory.WireOps - before).ShouldBe(6, "one multi-var request, then one per-tag read each");
|
||||
r.ShouldAllBe(s => s.StatusCode == 0u);
|
||||
factory.Created.Count.ShouldBe(1, "a refusal is not a reason to reopen");
|
||||
}
|
||||
|
||||
/// <summary>A refusal does not short-circuit the pass: the CPU answered, the handle is fine.</summary>
|
||||
[Fact]
|
||||
public async Task A_refusal_does_not_short_circuit_the_pass()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, factory) = await StartAsync(Options(maxGap: -1, FarApartWords()));
|
||||
using var _ = drv;
|
||||
factory.Read = (addr, _) => addr == "DB1.DBW0"
|
||||
? throw new Exception("Received error from PLC: Object does not exist.")
|
||||
: Task.FromResult<object?>((ushort)3);
|
||||
|
||||
var before = factory.WireOps;
|
||||
var r = await drv.ReadAsync(Refs(5), ct);
|
||||
|
||||
(factory.WireOps - before).ShouldBe(5);
|
||||
r.Skip(1).ShouldAllBe(s => s.StatusCode == 0u);
|
||||
}
|
||||
|
||||
/// <summary>Write batch: the first transport loss fails the rest of the batch without I/O.</summary>
|
||||
[Fact]
|
||||
public async Task Write_batch_stops_issuing_wire_ops_once_the_handle_is_dead()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, factory) = await StartAsync(Options(maxGap: -1, FarApartWords()));
|
||||
using var _ = drv;
|
||||
factory.Write = (_, _, _) => throw new System.IO.IOException("connection reset");
|
||||
|
||||
var before = factory.WireOps;
|
||||
var w = await drv.WriteAsync([.. Refs(5).Select(r => new WriteRequest(r, (ushort)1))], ct);
|
||||
|
||||
(factory.WireOps - before).ShouldBe(1);
|
||||
w.ShouldAllBe(x => x.StatusCode == BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
// Positive control: the next batch reopens and writes.
|
||||
factory.Write = (_, _, _) => Task.CompletedTask;
|
||||
(await drv.WriteAsync([.. Refs(5).Select(r => new WriteRequest(r, (ushort)1))], ct))
|
||||
.ShouldAllBe(x => x.StatusCode == 0u);
|
||||
}
|
||||
}
|
||||
@@ -25,26 +25,10 @@ public sealed class S7DriverCodeReviewFixTests
|
||||
[InlineData("C10")]
|
||||
public async Task Initialize_rejects_timer_or_counter_tag_with_NotSupportedException(string address)
|
||||
{
|
||||
// A Timer/Counter address parses cleanly but the read path has no decode case for it,
|
||||
// so it must fail fast at init rather than throw a misleading type-mismatch on every
|
||||
// read. The host is reserved-for-documentation so the TCP connect can never succeed —
|
||||
// the unsupported-address guard runs before the connect, so the NotSupportedException
|
||||
// is what surfaces.
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("Quirk", address, S7DataType.Int16)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-tc");
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain(address);
|
||||
|
||||
var health = drv.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Faulted, "an unsupported-address config error must Fault the driver");
|
||||
health.LastError.ShouldNotBeNull();
|
||||
// A Timer/Counter address typed anything but Float64 (Timer) / Int32 (Counter) cannot be decoded.
|
||||
// It is refused PER TAG at every gate; it used to throw at init and fault every tag on the PLC.
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(
|
||||
new S7TagDefinition("Quirk", address, S7DataType.Int16, Writable: false), address);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that initialize accepts DB and MIQ addresses without triggering the unsupported guard.</summary>
|
||||
@@ -102,19 +86,21 @@ public sealed class S7DriverCodeReviewFixTests
|
||||
[Fact]
|
||||
public async Task Initialize_rejects_a_timer_tag_supplied_only_through_driverConfigJson()
|
||||
{
|
||||
// The Timer/Counter guard must run against the *re-parsed* config, not just the
|
||||
// constructor options — proves Driver.S7-001 and Driver.S7-011 compose correctly.
|
||||
// The shape gate must run against the *re-parsed* config, not just the constructor options —
|
||||
// proves the per-tag skip and Driver.S7-011 compose correctly.
|
||||
var ctorOpts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250) };
|
||||
using var drv = new S7Driver(ctorOpts, "s7-cfg-tc");
|
||||
using var drv = new S7Driver(ctorOpts, "s7-cfg-tc", new ScriptedS7PlcFactory());
|
||||
|
||||
const string json = """
|
||||
{ "Host": "192.0.2.1", "TimeoutMs": 250,
|
||||
{ "Host": "192.0.2.1", "TimeoutMs": 250, "Probe": { "Enabled": false },
|
||||
"RawTags": [ { "RawPath": "TimerTag", "TagConfig": "{\"address\":\"T5\",\"dataType\":\"Int16\"}", "WriteIdempotent": false } ] }
|
||||
""";
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync(json, TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("T5");
|
||||
await drv.InitializeAsync(json, TestContext.Current.CancellationToken);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
var skipped = drv.GetSkippedTags().ShouldNotBeNull().Sample.ShouldHaveSingleItem();
|
||||
skipped.RawPath.ShouldBe("TimerTag");
|
||||
skipped.Reason.ShouldContain("T5");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reinitialize applies a changed driverConfigJson.</summary>
|
||||
|
||||
@@ -158,23 +158,9 @@ public sealed class S7DriverCodeReviewFixTests2
|
||||
[InlineData(S7DataType.DateTime)]
|
||||
public async Task Initialize_rejects_wide_type_at_non_byte_address_with_NotSupportedException(S7DataType dt)
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
// 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.)
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("X", "DB1.DBD0", dt)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, $"s7-wide-nonbyte-{dt}");
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain(dt.ToString());
|
||||
|
||||
var health = drv.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Faulted);
|
||||
// DB1.DBD0 is a DWord (non-byte) address — a wide type there would mis-frame its value. (At a
|
||||
// byte address like DB1.DBB0 the 8-byte numerics round-trip; see S7ScalarBlockTests.)
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("X", "DB1.DBD0", dt), dt.ToString(), "byte-addressed");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Initialize accepts implemented data types.</summary>
|
||||
|
||||
@@ -17,22 +17,11 @@ public sealed class S7DriverReadWriteTests
|
||||
[Fact]
|
||||
public async Task Initialize_rejects_invalid_tag_address_and_fails_fast()
|
||||
{
|
||||
// Bad address at init time must throw; the alternative (deferring the parse to the
|
||||
// first read) would surface the config bug as BadInternalError on every subsequent
|
||||
// Read which is impossible for an operator to diagnose from the OPC UA client.
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1", // reserved — will never complete TCP handshake
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-bad-tag");
|
||||
|
||||
// Either the TCP connect fails first (Exception) or the parser fails (FormatException)
|
||||
// — both are acceptable since both are init-time fail-fast. What matters is that we
|
||||
// don't return a "healthy" driver with a latent bad tag.
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
// A bad address is refused PER TAG at every gate (mapper/editor, deploy gate, driver init):
|
||||
// it reads BadNodeIdUnknown while its neighbours keep working. It used to throw at init and
|
||||
// fault every tag on the PLC, with the actor retrying the same init every 10 s forever.
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(
|
||||
new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16), "does not parse");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ReadAsync without initialize throws InvalidOperationException.</summary>
|
||||
|
||||
@@ -101,80 +101,32 @@ public sealed class S7DriverScaffoldTests
|
||||
[Fact]
|
||||
public async Task Initialize_rejects_wide_type_array_with_clear_message()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("Batch64", "DB1.DBB0", S7DataType.Int64, ArrayCount: 4)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-wide-array");
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("Batch64");
|
||||
ex.Message.ShouldContain("array", Case.Insensitive);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Batch64", "DB1.DBB0", S7DataType.Int64, ArrayCount: 4), "Int64", "array");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a wide scalar with a non-byte address (Float64 at DB1.DBW0) is rejected with a byte-address message.</summary>
|
||||
[Fact]
|
||||
public async Task Initialize_rejects_wide_scalar_with_non_byte_address()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("WideWord", "DB1.DBW0", S7DataType.Float64)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-wide-nonbyte");
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("WideWord");
|
||||
ex.Message.ShouldContain("byte", Case.Insensitive);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("WideWord", "DB1.DBW0", S7DataType.Float64), "Float64", "byte-addressed");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a Timer tag with the wrong DataType (Int32, not Float64) is rejected.</summary>
|
||||
[Fact]
|
||||
public async Task Initialize_rejects_Timer_tag_with_wrong_data_type()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("Timer5", "T5", S7DataType.Int32)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-timer-bad");
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("Timer5");
|
||||
ex.Message.ShouldContain("Float64");
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Timer5", "T5", S7DataType.Int32, Writable: false), "T5", "Float64");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a Counter tag with the wrong DataType (Float32, not Int32) is rejected.</summary>
|
||||
[Fact]
|
||||
public async Task Initialize_rejects_Counter_tag_with_wrong_data_type()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("Counter3", "C3", S7DataType.Float32)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-counter-bad");
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("Counter3");
|
||||
ex.Message.ShouldContain("Int32");
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Counter3", "C3", S7DataType.Float32, Writable: false), "C3", "Int32");
|
||||
}
|
||||
|
||||
// ── Phase 4d bundle-review fix — Writable Timer/Counter guard ───────────────────────
|
||||
@@ -188,42 +140,16 @@ public sealed class S7DriverScaffoldTests
|
||||
[Fact]
|
||||
public async Task Initialize_rejects_Timer_tag_declared_Writable()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("Timer5", "T5", S7DataType.Float64, Writable: true)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-timer-writable");
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("Timer5");
|
||||
ex.Message.ShouldContain("Writable", Case.Insensitive);
|
||||
ex.Message.ShouldContain("read-only", Case.Insensitive);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Timer5", "T5", S7DataType.Float64, Writable: true), "T5", "writable", "read-only");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a Counter tag declared Writable=true is rejected at init with a Writable message.</summary>
|
||||
[Fact]
|
||||
public async Task Initialize_rejects_Counter_tag_declared_Writable()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("Counter3", "C3", S7DataType.Int32, Writable: true)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-counter-writable");
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("Counter3");
|
||||
ex.Message.ShouldContain("Writable", Case.Insensitive);
|
||||
ex.Message.ShouldContain("read-only", Case.Insensitive);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Counter3", "C3", S7DataType.Int32, Writable: true), "C3", "writable", "read-only");
|
||||
}
|
||||
|
||||
// ── Driver.S7-015 / S7-016 — writable Bool array must fail fast at init ─────────────
|
||||
@@ -236,20 +162,8 @@ public sealed class S7DriverScaffoldTests
|
||||
[Fact]
|
||||
public async Task Initialize_rejects_writable_bool_array_tag_with_NotSupportedException()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("ArrTag", "DB1.DBX0.0", S7DataType.Bool, Writable: true, ArrayCount: 4)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-writable-bool-arr");
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("ArrTag");
|
||||
ex.Message.ShouldContain("Bool array", Case.Insensitive);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("ArrTag", "DB1.DBX0.0", S7DataType.Bool, Writable: true, ArrayCount: 4), "Bool array", "writable");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that writable non-bool arrays pass the init guard — the failure
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R07:H5 — overlapping lifecycle calls. <c>DriverInstanceActor</c> dispatches initialize on a
|
||||
/// fire-and-forget task and a reconnect retry can start a second attempt while the first is still in
|
||||
/// <c>OpenAsync</c>; an abandoned initialize can also still be running when the next shutdown starts.
|
||||
/// Every wait here is a condition wait on the fake's own signals, never a sleep.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7LifecycleGateTests
|
||||
{
|
||||
private static readonly TimeSpan Bound = TimeSpan.FromSeconds(10);
|
||||
|
||||
private static S7DriverOptions Options(bool probe = false) => new()
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromSeconds(30), // the fake's gated open is released by the test, never timed out
|
||||
Probe = new S7ProbeOptions { Enabled = probe, Interval = TimeSpan.FromSeconds(30) },
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)),
|
||||
};
|
||||
|
||||
/// <summary>A factory whose FIRST open blocks until released; later opens complete immediately.</summary>
|
||||
private sealed class FirstOpenBlocks
|
||||
{
|
||||
public readonly ScriptedS7PlcFactory Factory = new();
|
||||
public readonly TaskCompletionSource FirstOpenEntered = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public readonly TaskCompletionSource ReleaseFirstOpen = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public FirstOpenBlocks()
|
||||
{
|
||||
Factory.Open = async (index, ct) =>
|
||||
{
|
||||
if (index != 0) return;
|
||||
FirstOpenEntered.TrySetResult();
|
||||
await ReleaseFirstOpen.Task.WaitAsync(ct).ConfigureAwait(false);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A second initialize waits for the first instead of running beside it, then releases the first
|
||||
/// attempt's connection: exactly one live connection remains. Before the gate, attempt B opened
|
||||
/// its own connection while A was still in <c>OpenAsync</c>, and A's published connection (and
|
||||
/// probe loop) was overwritten and never released.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Overlapping_initializes_are_serialised_and_leave_one_live_connection()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var f = new FirstOpenBlocks();
|
||||
using var drv = new S7Driver(Options(), "s7-overlap", f.Factory);
|
||||
|
||||
var a = drv.InitializeAsync("{}", ct);
|
||||
await f.FirstOpenEntered.Task.WaitAsync(Bound, ct);
|
||||
var b = drv.InitializeAsync("{}", ct);
|
||||
|
||||
// Positive control first: B is genuinely pending (not finished, not faulted)...
|
||||
b.IsCompleted.ShouldBeFalse();
|
||||
// ...and while A holds the gate B has not created a connection of its own. B can only reach
|
||||
// its open after A completes, so this is not a race the test can lose by being slow.
|
||||
f.Factory.Created.Count.ShouldBe(1, "a second initialize must not open beside the first");
|
||||
|
||||
f.ReleaseFirstOpen.SetResult();
|
||||
await a.WaitAsync(Bound, ct);
|
||||
await b.WaitAsync(Bound, ct);
|
||||
|
||||
f.Factory.Created.Count.ShouldBe(2);
|
||||
f.Factory.Created[0].Disposed.ShouldBeTrue("B releases A's published connection before replacing it");
|
||||
f.Factory.Created[1].Disposed.ShouldBeFalse();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
(await drv.ReadAsync(["W0"], ct))[0].StatusCode.ShouldBe(0u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An initialize still running when a shutdown gives up waiting for it must not publish its
|
||||
/// connection, its health or its probe loop over the shut-down driver; it fails and disposes what
|
||||
/// it opened. Before the fix the late attempt published Healthy + a live connection after the
|
||||
/// shutdown returned.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_initialize_superseded_by_shutdown_publishes_nothing()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var f = new FirstOpenBlocks();
|
||||
using var drv = new S7Driver(Options(probe: true), "s7-superseded", f.Factory)
|
||||
{
|
||||
LifecycleGateTimeout = TimeSpan.FromSeconds(1),
|
||||
};
|
||||
|
||||
var init = drv.InitializeAsync("{}", ct);
|
||||
await f.FirstOpenEntered.Task.WaitAsync(Bound, ct);
|
||||
|
||||
// The shutdown cannot get the lifecycle gate (the attempt holds it), gives up after its bound
|
||||
// and tears down anyway.
|
||||
await drv.ShutdownAsync(ct).WaitAsync(Bound, ct);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
|
||||
// The abandoned attempt now completes its open...
|
||||
f.ReleaseFirstOpen.SetResult();
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() => init.WaitAsync(Bound, ct));
|
||||
ex.Message.ShouldContain("superseded");
|
||||
|
||||
// ...and published nothing: no connection kept, health untouched, the driver still shut down.
|
||||
f.Factory.Created.ShouldHaveSingleItem().Disposed.ShouldBeTrue();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown, "a superseded attempt must not write Healthy or Faulted");
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => drv.ReadAsync(["W0"], ct));
|
||||
|
||||
// Positive control: the driver is not wedged — a fresh initialize publishes normally.
|
||||
await drv.InitializeAsync("{}", ct).WaitAsync(Bound, ct);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
(await drv.ReadAsync(["W0"], ct))[0].StatusCode.ShouldBe(0u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review m-4: a retained poll that runs into a re-initialize in progress ("not initialized")
|
||||
/// must not write Degraded over the new attempt's Initializing. Waits on the poll's own log line
|
||||
/// (a counted signal), then asserts; the control is that a successful init then reports Healthy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_retained_poll_does_not_overwrite_a_reinitialize_in_progress()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var factory = new ScriptedS7PlcFactory();
|
||||
var secondOpenEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var releaseSecondOpen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
factory.Open = async (index, token) =>
|
||||
{
|
||||
if (index != 1) return;
|
||||
secondOpenEntered.TrySetResult();
|
||||
await releaseSecondOpen.Task.WaitAsync(token);
|
||||
};
|
||||
var log = new CapturingS7Logger();
|
||||
using var drv = new S7Driver(Options(), "s7-retained-poll", factory, log);
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
await drv.SubscribeAsync(["W0"], TimeSpan.FromMilliseconds(100), ct);
|
||||
|
||||
var reinit = drv.InitializeAsync("{}", ct);
|
||||
await secondOpenEntered.Task.WaitAsync(Bound, ct);
|
||||
|
||||
using (var cts = CancellationTokenSource.CreateLinkedTokenSource(ct))
|
||||
{
|
||||
cts.CancelAfter(Bound);
|
||||
while (log.Entries.Count(e => e.Message.Contains("during a lifecycle operation")) < 2)
|
||||
await Task.Delay(20, cts.Token); // condition wait on the poll's counted log line
|
||||
}
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Initializing, "a retained poll must not write over the new attempt");
|
||||
|
||||
releaseSecondOpen.SetResult();
|
||||
await reinit.WaitAsync(Bound, ct);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review m-8: the SYNCHRONOUS <c>Dispose()</c> racing a still-running <c>ShutdownAsync</c> is safe too
|
||||
/// — neither throws, the superseded init publishes nothing, and the driver ends torn down.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Sync_dispose_while_a_shutdown_is_still_running_is_safe()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var f = new FirstOpenBlocks();
|
||||
var drv = new S7Driver(Options(), "s7-sync-dispose-race", f.Factory) { LifecycleGateTimeout = TimeSpan.FromSeconds(1) };
|
||||
|
||||
var init = drv.InitializeAsync("{}", ct);
|
||||
await f.FirstOpenEntered.Task.WaitAsync(Bound, ct);
|
||||
|
||||
var shutdown = drv.ShutdownAsync(ct); // waits on the gate the blocked init holds
|
||||
Should.NotThrow(() => drv.Dispose());
|
||||
await shutdown.WaitAsync(Bound, ct);
|
||||
|
||||
f.ReleaseFirstOpen.SetResult();
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => init.WaitAsync(Bound, ct));
|
||||
f.Factory.Created.ShouldHaveSingleItem().Disposed.ShouldBeTrue();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispose racing a shutdown that is still running is safe: neither throws, and the driver ends
|
||||
/// torn down. The semaphores are no longer disposed, so the running shutdown cannot hit an
|
||||
/// ObjectDisposedException.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Dispose_while_a_shutdown_is_still_running_is_safe()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var f = new FirstOpenBlocks();
|
||||
var drv = new S7Driver(Options(), "s7-dispose-race", f.Factory) { LifecycleGateTimeout = TimeSpan.FromSeconds(1) };
|
||||
|
||||
var init = drv.InitializeAsync("{}", ct);
|
||||
await f.FirstOpenEntered.Task.WaitAsync(Bound, ct);
|
||||
|
||||
var shutdown = drv.ShutdownAsync(ct); // waits on the gate the blocked init holds
|
||||
var dispose = drv.DisposeAsync().AsTask();
|
||||
await shutdown.WaitAsync(Bound, ct);
|
||||
await dispose.WaitAsync(Bound, ct);
|
||||
|
||||
f.ReleaseFirstOpen.SetResult();
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => init.WaitAsync(Bound, ct));
|
||||
f.Factory.Created.ShouldHaveSingleItem().Disposed.ShouldBeTrue();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R07:H7 — (a) the background probe used to report the host <c>Stopped</c> whenever a long read
|
||||
/// pass held the gate past the probe deadline: contention is the connection being USED, not lost;
|
||||
/// (b) Test Connect ignored the config's <c>timeoutMs</c>. Waits are condition waits on counters and
|
||||
/// events; bounds are generous.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7ProbeContentionAndTimeoutTests
|
||||
{
|
||||
private static readonly TimeSpan Bound = TimeSpan.FromSeconds(15);
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, CancellationToken ct)
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(Bound);
|
||||
while (!condition())
|
||||
await Task.Delay(20, cts.Token); // polling a condition, not asserting on elapsed time
|
||||
}
|
||||
|
||||
/// <summary>A read pass holding the gate past the probe deadline does not make the host Stopped.</summary>
|
||||
[Fact]
|
||||
public async Task Gate_contention_is_no_verdict_but_a_real_probe_failure_still_is()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var factory = new ScriptedS7PlcFactory();
|
||||
var readEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var releaseRead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var drv = new S7Driver(new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromSeconds(30),
|
||||
Probe = new S7ProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50), Timeout = TimeSpan.FromSeconds(1) },
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)),
|
||||
}, "s7-probe-contention", factory);
|
||||
var transitions = new List<HostState>();
|
||||
drv.OnHostStatusChanged += (_, e) => { lock (transitions) transitions.Add(e.NewState); };
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
|
||||
await WaitUntilAsync(() => drv.GetHostStatuses()[0].State == HostState.Running, ct);
|
||||
|
||||
// A read that holds the gate until released.
|
||||
factory.Read = async (_, token) =>
|
||||
{
|
||||
readEntered.TrySetResult();
|
||||
await releaseRead.Task.WaitAsync(token);
|
||||
return (ushort)1;
|
||||
};
|
||||
var read = drv.ReadAsync(["W0"], ct);
|
||||
await readEntered.Task.WaitAsync(Bound, ct);
|
||||
|
||||
// At least two probe ticks give up on the gate...
|
||||
var contentionsBefore = drv.ProbeGateContentions;
|
||||
await WaitUntilAsync(() => drv.ProbeGateContentions >= contentionsBefore + 2, ct);
|
||||
// ...and the host is still Running — no Stopped transition was published for them.
|
||||
drv.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
|
||||
lock (transitions) transitions.ShouldNotContain(HostState.Stopped);
|
||||
|
||||
releaseRead.SetResult();
|
||||
(await read.WaitAsync(Bound, ct))[0].StatusCode.ShouldBe(0u);
|
||||
|
||||
// Positive control: a genuine probe failure still reports Stopped.
|
||||
factory.ReadStatus = _ => throw new System.IO.IOException("connection reset");
|
||||
await WaitUntilAsync(() => drv.GetHostStatuses()[0].State == HostState.Stopped, ct);
|
||||
}
|
||||
|
||||
/// <summary>Test Connect's handshake is bounded by the config's timeoutMs, not the caller's whole budget.</summary>
|
||||
[Fact]
|
||||
public async Task Test_connect_honours_the_config_timeoutMs()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
// A TCP endpoint that accepts and then never answers the ISO-on-TCP handshake.
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var held = new List<TcpClient>();
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { while (true) { var c = await listener.AcceptTcpClientAsync(ct); lock (held) held.Add(c); } }
|
||||
catch { /* stopped */ }
|
||||
}, ct);
|
||||
try
|
||||
{
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
var json = $$"""{"host":"127.0.0.1","port":{{port}},"timeoutMs":1000}""";
|
||||
|
||||
// The caller's budget (60 s) is far beyond the safety token (20 s): only a probe that honours
|
||||
// timeoutMs returns its own "handshake failed: timed out"; one that waits the caller's budget
|
||||
// is cut by the safety token and reports the caller-timeout message instead.
|
||||
using var safety = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
safety.CancelAfter(TimeSpan.FromSeconds(20));
|
||||
var r = await new S7DriverProbe().ProbeAsync(json, TimeSpan.FromSeconds(60), safety.Token);
|
||||
|
||||
r.Ok.ShouldBeFalse();
|
||||
r.Message!.ShouldContain("handshake failed: timed out");
|
||||
safety.IsCancellationRequested.ShouldBeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
lock (held) foreach (var c in held) c.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The config reader: any casing, positive integers only.</summary>
|
||||
[Fact]
|
||||
public void ReadTimeoutMs_reads_the_driver_key()
|
||||
{
|
||||
S7DriverProbe.ReadTimeoutMs("""{"TimeoutMs":2500}""").ShouldBe(2500);
|
||||
S7DriverProbe.ReadTimeoutMs("""{"timeoutms":10}""").ShouldBe(10);
|
||||
S7DriverProbe.ReadTimeoutMs("""{"timeoutMs":0}""").ShouldBeNull();
|
||||
S7DriverProbe.ReadTimeoutMs("""{"host":"x"}""").ShouldBeNull();
|
||||
S7DriverProbe.ReadTimeoutMs("not json").ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
using S7.Net;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R07:H4 — a deterministic PLC refusal (S7.Net <c>ReadData</c> / <c>WriteData</c> carrying an S7
|
||||
/// item error such as "object does not exist", not a socket/timeout/framing fault) proves the CPU
|
||||
/// answered. It is per-tag Bad with a once-per-tag Warning and NO Degraded; it advances
|
||||
/// <c>LastSuccessfulRead</c>, so a driver whose every tag is refused (wrong DB number) no longer
|
||||
/// trips the 90 s comms-loss watchdog against a reachable PLC.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7RefusalHealthTests
|
||||
{
|
||||
private const uint BadDeviceFailure = 0x808B0000u;
|
||||
|
||||
/// <summary>
|
||||
/// The shape S7.Net's READ paths really raise for a CPU item error — a BARE
|
||||
/// <see cref="Exception"/>, live-probed against the snap7 fixture (not a PlcException).
|
||||
/// </summary>
|
||||
private static Exception ReadRefusal() => new("Received error from PLC: Object does not exist.");
|
||||
|
||||
private static PlcException WriteRefusal() =>
|
||||
new(ErrorCode.WriteData, new Exception("Received error from PLC: Address out of range."));
|
||||
|
||||
private static S7DriverOptions Options() => new()
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
// Grouping off: one wire request per tag keeps each tag's outcome its own.
|
||||
SpanReadMaxGapBytes = -1,
|
||||
RawTags = S7RawTags.Entries(
|
||||
new S7TagDefinition("Missing", "DB99.DBW0", S7DataType.UInt16),
|
||||
new S7TagDefinition("Good", "DB1.DBW0", S7DataType.UInt16)),
|
||||
};
|
||||
|
||||
/// <summary>A stepped pass clock, so "LastSuccessfulRead advanced" is asserted STRICTLY.</summary>
|
||||
private sealed class PassClock
|
||||
{
|
||||
public DateTime Now { get; private set; } = new(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>Moves the clock one second on and returns the new time.</summary>
|
||||
public DateTime Step() => Now = Now.AddSeconds(1);
|
||||
}
|
||||
|
||||
private static async Task<(S7Driver Driver, ScriptedS7PlcFactory Factory, CapturingS7Logger Log, PassClock Clock)> StartAsync()
|
||||
{
|
||||
var factory = new ScriptedS7PlcFactory();
|
||||
var log = new CapturingS7Logger();
|
||||
var clock = new PassClock();
|
||||
var drv = new S7Driver(Options(), "s7-refusal", factory, log) { UtcNow = () => clock.Now };
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldBe(clock.Now, "init stamps the pass clock");
|
||||
return (drv, factory, log, clock);
|
||||
}
|
||||
|
||||
/// <summary>A refused tag is Bad, health stays Healthy, LastSuccessfulRead advances, and it warns once.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_refusal_is_per_tag_bad_with_no_health_impact_and_one_warning()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, factory, log, clock) = await StartAsync();
|
||||
using var _ = drv;
|
||||
factory.Read = (addr, _) => addr == "DB99.DBW0" ? throw ReadRefusal() : Task.FromResult<object?>((ushort)7);
|
||||
|
||||
for (var pass = 0; pass < 3; pass++)
|
||||
{
|
||||
var at = clock.Step();
|
||||
var r = await drv.ReadAsync(["Missing"], ct);
|
||||
r[0].StatusCode.ShouldBe(BadDeviceFailure);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a refusal is not a comms fault");
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldBe(at,
|
||||
"the CPU answered, so the all-refused pass advances LastSuccessfulRead to the pass time");
|
||||
}
|
||||
log.Warnings.Count(w => w.Contains("Missing")).ShouldBe(1, "one Warning per refused tag, not one per poll");
|
||||
|
||||
// Positive control: the good tag still reads, and nothing reopened the connection.
|
||||
(await drv.ReadAsync(["Good"], ct))[0].StatusCode.ShouldBe(0u);
|
||||
factory.Created.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A refusal after a comms fault restores Healthy — the CPU is back and answering. Before the
|
||||
/// fix the refusal latched Degraded with LastSuccessfulRead frozen, which is exactly the
|
||||
/// watchdog's trip condition.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_refusal_after_a_comms_fault_restores_healthy()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, factory, _, clock) = await StartAsync();
|
||||
using var __ = drv;
|
||||
|
||||
// Positive control: a genuine comms fault DOES degrade, and leaves LastSuccessfulRead frozen.
|
||||
var initStamp = clock.Now;
|
||||
clock.Step();
|
||||
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
|
||||
await drv.ReadAsync(["Missing"], ct);
|
||||
var degraded = drv.GetHealth();
|
||||
degraded.State.ShouldBe(DriverState.Degraded);
|
||||
degraded.LastSuccessfulRead.ShouldBe(initStamp);
|
||||
|
||||
var at = clock.Step();
|
||||
factory.Read = (addr, _) => addr == "DB99.DBW0" ? throw ReadRefusal() : Task.FromResult<object?>((ushort)7);
|
||||
var r = await drv.ReadAsync(["Missing"], ct);
|
||||
r[0].StatusCode.ShouldBe(BadDeviceFailure);
|
||||
var after = drv.GetHealth();
|
||||
after.State.ShouldBe(DriverState.Healthy);
|
||||
after.LastSuccessfulRead.ShouldBe(at);
|
||||
}
|
||||
|
||||
/// <summary>The ReadData-wrapped shape of a refusal is classified the same way.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_data_wrapped_refusal_is_also_a_refusal()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, factory, _, clock) = await StartAsync();
|
||||
using var __ = drv;
|
||||
factory.Read = (_, _) => throw new PlcException(ErrorCode.ReadData, ReadRefusal());
|
||||
|
||||
(await drv.ReadAsync(["Missing"], ct))[0].StatusCode.ShouldBe(BadDeviceFailure);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The bare read-path shape of access denied (PUT/GET disabled) is the Faulted config alert.
|
||||
/// Before, only a PlcException-wrapped "not allowed" was recognised, so a real CPU's read
|
||||
/// refusal fell through to BadCommunicationError + Degraded.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_bare_access_denied_read_is_the_faulted_config_alert()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, factory, _, clock) = await StartAsync();
|
||||
using var __ = drv;
|
||||
factory.Read = (_, _) => throw new Exception("Received error from PLC: Accessing object not allowed.");
|
||||
|
||||
(await drv.ReadAsync(["Good"], ct))[0].StatusCode.ShouldBe(0x803D0000u); // BadNotSupported
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
drv.GetHealth().LastError!.ShouldContain("PUT/GET");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review I-1: an access-denied CPU (PUT/GET left at its factory default — EVERY read denied) is
|
||||
/// reachable and answering, so each pass advances LastSuccessfulRead while staying Faulted.
|
||||
/// Before, the pass stayed Faulted with the stamp frozen — the comms-loss watchdog's trip
|
||||
/// condition, so the actor reconnected + re-subscribed every 90 s, forever, which can never fix
|
||||
/// a PUT/GET setting. The control: a comms fault afterwards leaves the stamp frozen.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_all_access_denied_pass_stays_faulted_but_advances_last_successful_read()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, factory, _, clock) = await StartAsync();
|
||||
using var __ = drv;
|
||||
factory.Read = (_, _) => throw new Exception("Received error from PLC: Accessing object not allowed.");
|
||||
|
||||
DateTime last = default;
|
||||
for (var pass = 0; pass < 2; pass++)
|
||||
{
|
||||
last = clock.Step();
|
||||
await drv.ReadAsync(["Good", "Missing"], ct);
|
||||
var h = drv.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Faulted, "the config alert is kept");
|
||||
h.LastSuccessfulRead.ShouldBe(last, "the CPU answered, so the stamp advances every pass");
|
||||
}
|
||||
|
||||
// Positive control: a real comms loss does NOT advance it (and the sticky Faulted outranks it).
|
||||
clock.Step();
|
||||
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
|
||||
await drv.ReadAsync(["Good"], ct);
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldBe(last);
|
||||
}
|
||||
|
||||
/// <summary>The write-side access-denied is an answer too, and its sticky Faulted still clears only on a real success.</summary>
|
||||
[Fact]
|
||||
public async Task A_write_access_denied_advances_last_successful_read_and_stays_sticky()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, factory, _, clock) = await StartAsync();
|
||||
using var __ = drv;
|
||||
factory.Write = (_, _, _) => throw new PlcException(ErrorCode.WriteData,
|
||||
new Exception("Received error from PLC: Accessing object not allowed."));
|
||||
|
||||
var at = clock.Step();
|
||||
(await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct))[0].StatusCode.ShouldBe(0x803D0000u);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldBe(at);
|
||||
|
||||
// A clean read leaves the write-side Faulted in place (sticky) …
|
||||
clock.Step();
|
||||
await drv.ReadAsync(["Good"], ct);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
|
||||
// … and a real write success clears it.
|
||||
factory.Write = (_, _, _) => Task.CompletedTask;
|
||||
clock.Step();
|
||||
await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review m-7: a transport fault whose text happens to contain "not allowed" reopens (Degraded),
|
||||
/// and only S7.Net's exact "Accessing object not allowed" is the access-denied alert.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Only_the_exact_access_denied_wording_without_a_transport_fault_is_access_denied()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, factory, _, clock) = await StartAsync();
|
||||
using var __ = drv;
|
||||
|
||||
clock.Step();
|
||||
factory.Write = (_, _, _) => throw new PlcException(ErrorCode.WriteData,
|
||||
new System.IO.IOException("Accessing object not allowed: socket closed by peer"));
|
||||
(await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct))[0].StatusCode
|
||||
.ShouldNotBe(0x803D0000u, "not the access-denied BadNotSupported");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "a transport fault is not a config alert");
|
||||
|
||||
S7Driver.IsAccessDenied(new PlcException(ErrorCode.WriteData, new Exception("operation not allowed"))).ShouldBeFalse();
|
||||
S7Driver.IsAccessDenied(new Exception("Received error from PLC: Accessing object not allowed.")).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>A ReadData wrapping a socket fault is still a comms fault (Degraded), not a refusal.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_data_wrapping_a_socket_fault_still_degrades()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, factory, _, clock) = await StartAsync();
|
||||
using var __ = drv;
|
||||
factory.Read = (_, _) => throw new PlcException(ErrorCode.ReadData, new System.IO.IOException("connection reset"));
|
||||
|
||||
await drv.ReadAsync(["Good"], ct);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
/// <summary>A write refusal is per-tag Bad, counts as answered and leaves health alone.</summary>
|
||||
[Fact]
|
||||
public async Task A_write_refusal_is_per_tag_bad_and_counts_as_answered()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, factory, log, clock) = await StartAsync();
|
||||
using var _ = drv;
|
||||
factory.Write = (addr, _, _) => addr == "DB99.DBW0" ? throw WriteRefusal() : Task.CompletedTask;
|
||||
|
||||
// Put the driver into a comms-fault Degraded first; the refusal must lift it.
|
||||
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
|
||||
await drv.ReadAsync(["Good"], ct);
|
||||
var degraded = drv.GetHealth();
|
||||
degraded.State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var at = clock.Step();
|
||||
var w = await drv.WriteAsync([new WriteRequest("Missing", (ushort)1)], ct);
|
||||
w[0].StatusCode.ShouldBe(BadDeviceFailure);
|
||||
var h = drv.GetHealth();
|
||||
h.LastSuccessfulRead.ShouldBe(at, "a write refusal is an answer: the stamp advances strictly");
|
||||
// The Degraded was written by the READ side (its own message), so it is the read pass's to clear.
|
||||
h.State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
var after = drv.GetHealth();
|
||||
after.LastSuccessfulRead!.Value.ShouldBeGreaterThan(degraded.LastSuccessfulRead!.Value);
|
||||
log.Warnings.Count(m => m.Contains("Missing")).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A good write clears a Degraded the write side wrote. Before the fix a success-only batch
|
||||
/// never wrote health, so a transient write fault latched Degraded until some read pass ran.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_good_write_clears_a_write_side_degraded()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, factory, _, clock) = await StartAsync();
|
||||
using var _ = drv;
|
||||
|
||||
factory.Write = (_, _, _) => throw new System.IO.IOException("connection reset");
|
||||
var bad = await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct);
|
||||
bad[0].StatusCode.ShouldNotBe(0u);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: a comms fault on write degrades");
|
||||
|
||||
factory.Write = (_, _, _) => Task.CompletedTask;
|
||||
var good = await drv.WriteAsync([new WriteRequest("Good", (ushort)2)], ct);
|
||||
good[0].StatusCode.ShouldBe(0u);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
}
|
||||
@@ -202,9 +202,10 @@ public sealed class S7SpanReadParityTests
|
||||
public async Task A_span_read_failure_degrades_every_member_like_the_per_tag_path()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
// ErrorCode.ReadData with a message that is NOT access-denied ("not allowed") — a genuine
|
||||
// device-layer fault, which the driver maps to BadDeviceFailure + Degraded.
|
||||
var fault = new PlcException(ErrorCode.ReadData, "S7 CPU reported a device fault");
|
||||
// A device-layer fault that is neither a refusal (a ReadData item error is per-tag Bad with
|
||||
// no health impact since R07:H4, and a refused span falls back per member since R07:P2) nor
|
||||
// connection-fatal — the driver maps it to BadDeviceFailure + Degraded on every member.
|
||||
var fault = new PlcException(ErrorCode.SendData, "S7 CPU reported a device fault");
|
||||
|
||||
var groupingFactory = new ImageS7PlcFactory(Images()) { ReadFault = fault };
|
||||
var perTagFactory = new ImageS7PlcFactory(Images()) { ReadFault = fault };
|
||||
@@ -468,8 +469,11 @@ public sealed class S7SpanReadParityTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The SPAN path obeys the same rule: one member that fails to decode leaves the pass
|
||||
/// <c>Degraded</c> even though a later member of the same block decoded fine.
|
||||
/// The SPAN path guards each member's decode: one member that fails to decode is Bad on its
|
||||
/// own while a later member of the same block decodes fine. S7.Net rejects the malformed
|
||||
/// STRING with a <c>ReadData</c> <see cref="PlcException"/> — the CPU answered, the buffer is
|
||||
/// what is wrong — so since R07:H4 it is a per-tag refusal with no Degraded (like the
|
||||
/// codec's own <c>BadOutOfRange</c>), and LastSuccessfulRead still advances.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The block read itself succeeds — this is the per-member decode guard, the span-path
|
||||
@@ -480,7 +484,7 @@ public sealed class S7SpanReadParityTests
|
||||
/// last-writer-wins used to erase.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task One_undecodable_span_member_leaves_the_pass_Degraded()
|
||||
public async Task One_undecodable_span_member_is_bad_alone_without_degrading()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
// The STRING occupies DB1 bytes 100..111 (StringLength + 2), so the word at 112 is
|
||||
@@ -516,7 +520,7 @@ public sealed class S7SpanReadParityTests
|
||||
results[1].Value.ShouldNotBeNull();
|
||||
|
||||
var health = driver.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Degraded, "a span member that failed to decode must not be erased");
|
||||
health.State.ShouldBe(DriverState.Healthy, "an undecodable buffer is a data problem, not a comms fault");
|
||||
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
|
||||
}
|
||||
|
||||
@@ -542,7 +546,7 @@ public sealed class S7SpanReadParityTests
|
||||
// which is the Faulted config alert. B: an ordinary device-layer fault, merely Degraded,
|
||||
// observed AFTER it. C: no fault at all.
|
||||
factory.PerTagFaults["DB1.DBW0"] = new PlcException(ErrorCode.ReadData, "Accessing object not allowed");
|
||||
factory.PerTagFaults["DB1.DBW2"] = new PlcException(ErrorCode.ReadData, "S7 CPU reported a device fault");
|
||||
factory.PerTagFaults["DB1.DBW2"] = new PlcException(ErrorCode.SendData, "S7 CPU reported a device fault");
|
||||
// maxGap -1 turns grouping off entirely, so every tag takes the per-tag read path and the
|
||||
// faults can be aimed at individual addresses.
|
||||
using var driver = new S7Driver(Options(NarrowTags(), maxGap: -1), "s7-access-denied", factory);
|
||||
@@ -584,7 +588,7 @@ public sealed class S7SpanReadParityTests
|
||||
var factory = new ImageS7PlcFactory(Images());
|
||||
// A: an ordinary device-layer fault (Degraded) FIRST. B: the access-denied config alert
|
||||
// (Faulted) after it. C: no fault at all.
|
||||
factory.PerTagFaults["DB1.DBW0"] = new PlcException(ErrorCode.ReadData, "S7 CPU reported a device fault");
|
||||
factory.PerTagFaults["DB1.DBW0"] = new PlcException(ErrorCode.SendData, "S7 CPU reported a device fault");
|
||||
factory.PerTagFaults["DB1.DBW2"] = new PlcException(ErrorCode.ReadData, "Accessing object not allowed");
|
||||
using var driver = new S7Driver(Options(NarrowTags(), maxGap: -1), "s7-escalation-message", factory);
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R07:P2 — a span's block is the union of its members' ranges, so ONE member that runs past the
|
||||
/// DB's real end (or over-declares a STRING) made the CPU refuse the whole block and every
|
||||
/// neighbour inside it went Bad on every poll. A refused span now re-reads its members one at a
|
||||
/// time for that pass and remembers which tags must not be grouped, so later passes group only the
|
||||
/// members that can be grouped.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7SpanRefusalFallbackTests
|
||||
{
|
||||
private const uint BadDeviceFailure = 0x808B0000u;
|
||||
|
||||
/// <summary>DB1 is 4 bytes long on this fake CPU: anything reaching past byte 4 is refused.</summary>
|
||||
private const int DbLength = 4;
|
||||
|
||||
private static Exception OutOfRange() => new("Received error from PLC: Address out of range.");
|
||||
|
||||
private static (S7Driver Driver, ScriptedS7PlcFactory Factory, Func<int> BlockReads, Func<int> TagReads) Start(
|
||||
params S7TagDefinition[] tags)
|
||||
{
|
||||
var factory = new ScriptedS7PlcFactory();
|
||||
var blockReads = 0;
|
||||
var tagReads = 0;
|
||||
factory.ReadBytes = (_, _, start, count, _) =>
|
||||
{
|
||||
Interlocked.Increment(ref blockReads);
|
||||
if (start + count > DbLength) throw OutOfRange();
|
||||
var b = new byte[count];
|
||||
for (var i = 0; i < count; i++) b[i] = (byte)(start + i + 1);
|
||||
return Task.FromResult(b);
|
||||
};
|
||||
factory.Read = (addr, _) =>
|
||||
{
|
||||
Interlocked.Increment(ref tagReads);
|
||||
var offset = int.Parse(addr[(addr.LastIndexOf('W') + 1)..], System.Globalization.CultureInfo.InvariantCulture);
|
||||
if (offset + 2 > DbLength) throw OutOfRange();
|
||||
return Task.FromResult<object?>((ushort)(((offset + 1) << 8) | (offset + 2)));
|
||||
};
|
||||
var drv = new S7Driver(new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
RawTags = S7RawTags.Entries(tags),
|
||||
}, "s7-span-fallback", factory);
|
||||
return (drv, factory, () => Volatile.Read(ref blockReads), () => Volatile.Read(ref tagReads));
|
||||
}
|
||||
|
||||
private static S7TagDefinition Word(string name, int offset) =>
|
||||
new(name, $"DB1.DBW{offset}", S7DataType.UInt16, Writable: false);
|
||||
|
||||
/// <summary>
|
||||
/// The over-running member is refused on its own; its neighbours are served from the very first
|
||||
/// pass, and from the second pass on the neighbours are grouped again WITHOUT the bad member.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_refused_span_falls_back_per_member_and_stops_grouping_the_bad_member()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, _, blockReads, tagReads) = Start(Word("A", 0), Word("B", 2), Word("C", 4));
|
||||
using var _ = drv;
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
string[] refs = ["A", "B", "C"];
|
||||
|
||||
// Pass 1: the 6-byte span is refused, then each member is read on its own.
|
||||
var r1 = await drv.ReadAsync(refs, ct);
|
||||
r1[0].StatusCode.ShouldBe(0u);
|
||||
r1[0].Value.ShouldBe((ushort)0x0102);
|
||||
r1[1].StatusCode.ShouldBe(0u);
|
||||
r1[1].Value.ShouldBe((ushort)0x0304);
|
||||
r1[2].StatusCode.ShouldBe(BadDeviceFailure);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
blockReads().ShouldBe(1);
|
||||
tagReads().ShouldBe(3);
|
||||
|
||||
// Pass 2: A+B ride ONE block read again (the planner no longer sees C); C reads alone.
|
||||
var r2 = await drv.ReadAsync(refs, ct);
|
||||
r2[0].Value.ShouldBe((ushort)0x0102);
|
||||
r2[1].Value.ShouldBe((ushort)0x0304);
|
||||
r2[2].StatusCode.ShouldBe(BadDeviceFailure);
|
||||
blockReads().ShouldBe(2, "A+B grouped again, without the refused member");
|
||||
tagReads().ShouldBe(4, "only C took the per-tag path on pass 2");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the block is refused but every member reads fine alone, the span itself is the problem:
|
||||
/// all of its members stop being grouped (and stop re-trying the refused block every pass).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_refused_span_whose_members_all_read_alone_is_not_grouped_again()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var factory = new ScriptedS7PlcFactory();
|
||||
var blockReads = 0;
|
||||
factory.ReadBytes = (_, _, _, _, _) => { Interlocked.Increment(ref blockReads); throw OutOfRange(); };
|
||||
factory.Read = (_, _) => Task.FromResult<object?>((ushort)7);
|
||||
using var drv = new S7Driver(new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
RawTags = S7RawTags.Entries(Word("A", 0), Word("B", 2)),
|
||||
}, "s7-span-fallback-all", factory);
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
|
||||
(await drv.ReadAsync(["A", "B"], ct)).ShouldAllBe(s => s.StatusCode == 0u);
|
||||
(await drv.ReadAsync(["A", "B"], ct)).ShouldAllBe(s => s.StatusCode == 0u);
|
||||
Volatile.Read(ref blockReads).ShouldBe(1, "the refused block is not re-issued on later passes");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Positive control: a span failure that is NOT a refusal (a device-layer fault) does not fall
|
||||
/// back — re-reading N members one by one against a failing device would only multiply the fault.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_non_refusal_span_failure_does_not_fall_back()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var factory = new ScriptedS7PlcFactory
|
||||
{
|
||||
ReadBytes = (_, _, _, _, _) => throw new global::S7.Net.PlcException(global::S7.Net.ErrorCode.SendData, "device fault"),
|
||||
};
|
||||
using var drv = new S7Driver(new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
RawTags = S7RawTags.Entries(Word("A", 0), Word("B", 2)),
|
||||
}, "s7-span-nofallback", factory);
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
|
||||
var before = factory.WireOps;
|
||||
var r = await drv.ReadAsync(["A", "B"], ct);
|
||||
r.ShouldAllBe(s => s.StatusCode == BadDeviceFailure);
|
||||
(factory.WireOps - before).ShouldBe(1);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review m-5, the multi-var sibling: a leftover chunk holding one refused scalar used to fall back
|
||||
/// per tag on EVERY poll (1 + N requests). The member refused on its own is now memoised, so the
|
||||
/// next pass batches the rest and reads only the bad one alone.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_refused_multi_var_chunk_memoises_only_the_member_refused_alone()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var factory = new ScriptedS7PlcFactory();
|
||||
var multiCalls = 0;
|
||||
factory.ReadMulti = (items, _) =>
|
||||
{
|
||||
Interlocked.Increment(ref multiCalls);
|
||||
if (items.Any(i => i.DB == 3))
|
||||
// The live snap7 shape of a multi-var request with one refused item.
|
||||
throw new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData,
|
||||
new global::S7.Net.PlcException(global::S7.Net.ErrorCode.WrongNumberReceivedBytes));
|
||||
foreach (var i in items) i.Value = (ushort)5;
|
||||
return Task.FromResult<IReadOnlyList<global::S7.Net.Types.DataItem>>(items);
|
||||
};
|
||||
factory.Read = (addr, _) => addr.StartsWith("DB3.", StringComparison.Ordinal)
|
||||
? throw new Exception("Received error from PLC: Object does not exist.")
|
||||
: Task.FromResult<object?>((ushort)5);
|
||||
using var drv = new S7Driver(new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
RawTags = S7RawTags.Entries(
|
||||
new S7TagDefinition("A", "DB1.DBW0", S7DataType.UInt16, Writable: false),
|
||||
new S7TagDefinition("B", "DB2.DBW0", S7DataType.UInt16, Writable: false),
|
||||
new S7TagDefinition("Bad", "DB3.DBW0", S7DataType.UInt16, Writable: false)),
|
||||
}, "s7-mv-memo", factory);
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
string[] refs = ["A", "B", "Bad"];
|
||||
|
||||
var before = factory.WireOps;
|
||||
var r1 = await drv.ReadAsync(refs, ct);
|
||||
r1[0].StatusCode.ShouldBe(0u);
|
||||
r1[1].StatusCode.ShouldBe(0u);
|
||||
r1[2].StatusCode.ShouldBe(BadDeviceFailure);
|
||||
(factory.WireOps - before).ShouldBe(4, "pass 1: one refused multi-var, then one per-tag read each");
|
||||
|
||||
before = factory.WireOps;
|
||||
var r2 = await drv.ReadAsync(refs, ct);
|
||||
r2[0].StatusCode.ShouldBe(0u);
|
||||
r2[1].StatusCode.ShouldBe(0u);
|
||||
r2[2].StatusCode.ShouldBe(BadDeviceFailure);
|
||||
(factory.WireOps - before).ShouldBe(2, "pass 2: A+B ride one multi-var, Bad reads alone");
|
||||
Volatile.Read(ref multiCalls).ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>The memo is per generation: a re-initialize (e.g. a corrected DB) groups everything again.</summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_clears_the_do_not_group_memo()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, _, blockReads, tagReads) = Start(Word("A", 0), Word("B", 2), Word("C", 4));
|
||||
using var _ = drv;
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
await drv.ReadAsync(["A", "B", "C"], ct);
|
||||
blockReads().ShouldBe(1);
|
||||
tagReads().ShouldBe(3);
|
||||
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
await drv.ReadAsync(["A", "B", "C"], ct);
|
||||
// With the memo still in force pass 2 would be one A+B block read plus ONE per-tag read (C).
|
||||
// Cleared, the full A..C span is tried again, refused again, and all three re-read alone.
|
||||
blockReads().ShouldBe(2);
|
||||
tagReads().ShouldBe(6, "after re-init the full span is tried again (and refused again)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R07:H3 / R07:U4 — the tag shapes the driver cannot serve are refused per tag at the mapper,
|
||||
/// the deploy gate and the driver's init (see <see cref="S7TagShapeRules"/>), instead of faulting
|
||||
/// the whole driver at init or being silently mis-read.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7TagShapeGateTests
|
||||
{
|
||||
/// <summary>A typo'd area/size letter is refused per tag (it used to throw FormatException at init).</summary>
|
||||
[Fact]
|
||||
public Task Unparseable_address_is_refused_per_tag()
|
||||
=> S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Typo", "DB1.DBZ0", S7DataType.Int16), "DB1.DBZ0", "does not parse");
|
||||
|
||||
/// <summary>
|
||||
/// R07:U4 — a Bool array reads bit 0 of each byte, so a non-zero start bit was silently ignored
|
||||
/// (<c>DB1.DBX0.3</c> returned bit 0 of bytes 0..7). It is refused at authoring now.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public Task Bool_array_starting_at_a_nonzero_bit_is_refused()
|
||||
=> S7TagSkipAssert.RefusedPerTagAsync(
|
||||
new S7TagDefinition("OffsetBits", "DB1.DBX0.3", S7DataType.Bool, Writable: false, ArrayCount: 8),
|
||||
"Bool array", "bit 3");
|
||||
|
||||
/// <summary>A narrow type at the wrong access width fails every read with a type mismatch; refused up front.</summary>
|
||||
/// <param name="address">The address.</param>
|
||||
/// <param name="dt">The declared type, which does not match the address's access width.</param>
|
||||
[Theory]
|
||||
[InlineData("DB1.DBW0", S7DataType.Int32)]
|
||||
[InlineData("DB1.DBD0", S7DataType.Int16)]
|
||||
[InlineData("DB1.DBB0", S7DataType.Bool)]
|
||||
[InlineData("M0.0", S7DataType.Byte)]
|
||||
[InlineData("MW0", S7DataType.Float32)]
|
||||
public Task Narrow_type_at_the_wrong_access_width_is_refused(string address, S7DataType dt)
|
||||
=> S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Mismatch", address, dt), "access width");
|
||||
|
||||
/// <summary>Positive control: the shapes the driver serves are all still accepted by the mapper and the gate.</summary>
|
||||
/// <param name="address">The address.</param>
|
||||
/// <param name="dt">The declared type.</param>
|
||||
/// <param name="arrayCount">The array count, or 0 for a scalar.</param>
|
||||
/// <param name="writable">Whether the tag is writable.</param>
|
||||
[Theory]
|
||||
[InlineData("DB1.DBX0.0", S7DataType.Bool, 8, false)]
|
||||
[InlineData("DB1.DBX50.3", S7DataType.Bool, 0, true)]
|
||||
[InlineData("DB1.DBB0", S7DataType.Byte, 4, true)]
|
||||
[InlineData("DB1.DBW0", S7DataType.Int16, 0, true)]
|
||||
[InlineData("MW2", S7DataType.UInt16, 3, true)]
|
||||
[InlineData("DB1.DBD0", S7DataType.Float32, 0, true)]
|
||||
[InlineData("QD4", S7DataType.Int32, 2, true)]
|
||||
[InlineData("DB1.DBB0", S7DataType.Float64, 0, true)]
|
||||
[InlineData("DB1.DBB0", S7DataType.String, 0, true)]
|
||||
[InlineData("T5", S7DataType.Float64, 0, false)]
|
||||
[InlineData("C3", S7DataType.Int32, 0, false)]
|
||||
public void Servable_shapes_are_accepted(string address, S7DataType dt, int arrayCount, bool writable)
|
||||
{
|
||||
var def = new S7TagDefinition("Ok", address, dt, Writable: writable, ArrayCount: arrayCount == 0 ? null : arrayCount);
|
||||
var blob = S7TagDefinitionFactory.ToTagConfig(def);
|
||||
S7TagDefinitionFactory.FromTagConfig(blob, "Ok", out var mapped, out var reason).ShouldBeTrue(reason);
|
||||
mapped.Address.ShouldBe(address);
|
||||
S7TagDefinitionFactory.Inspect(blob).ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared assertion for an S7 tag shape the driver cannot serve. It must be refused PER TAG at
|
||||
/// every gate, never fault the whole driver: the mapper (and so the AdminUI editor) refuses it with
|
||||
/// the reason, the deploy gate (<see cref="S7TagDefinitionFactory.Inspect"/>) warns, and a driver
|
||||
/// carrying it next to a good tag initializes Healthy, reports the bad one through
|
||||
/// <see cref="ISkippedTagReporter"/>, and still serves the good one (the positive control).
|
||||
/// </summary>
|
||||
internal static class S7TagSkipAssert
|
||||
{
|
||||
private const string GoodName = "Good";
|
||||
|
||||
/// <summary>Asserts <paramref name="bad"/> is refused per tag at the mapper, the deploy gate and the driver.</summary>
|
||||
/// <param name="bad">The unservable tag.</param>
|
||||
/// <param name="reasonFragments">Fragments the operator-facing rejection reason must contain.</param>
|
||||
/// <returns>A task that completes when every gate has been checked.</returns>
|
||||
public static async Task RefusedPerTagAsync(S7TagDefinition bad, params string[] reasonFragments)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var blob = S7TagDefinitionFactory.ToTagConfig(bad);
|
||||
|
||||
// Mapper (and therefore the AdminUI editor, which asks it) — refused, with the reason.
|
||||
S7TagDefinitionFactory.FromTagConfig(blob, bad.Name, out _, out var reason).ShouldBeFalse();
|
||||
reason.ShouldNotBeNull();
|
||||
foreach (var f in reasonFragments) reason.ShouldContain(f, Case.Insensitive);
|
||||
|
||||
// Deploy gate — warns with the same reason.
|
||||
var warnings = S7TagDefinitionFactory.Inspect(blob);
|
||||
warnings.ShouldHaveSingleItem().ShouldContain(reason);
|
||||
|
||||
// Driver — initializes Healthy with the bad tag skipped and the good tag served.
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
RawTags = S7RawTags.Entries(bad, new S7TagDefinition(GoodName, "DB1.DBW0", S7DataType.UInt16, Writable: false)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-skip-" + bad.Name, new ScriptedS7PlcFactory());
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "one unservable tag must not fault the driver");
|
||||
var skipped = drv.GetSkippedTags().ShouldNotBeNull();
|
||||
skipped.Sample.ShouldHaveSingleItem().RawPath.ShouldBe(bad.Name);
|
||||
skipped.Sample[0].Reason.ShouldBe(reason);
|
||||
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await drv.DiscoverAsync(builder, ct);
|
||||
builder.Variables.Select(v => v.Name).ShouldBe([GoodName]);
|
||||
|
||||
var results = await drv.ReadAsync([bad.Name, GoodName], ct);
|
||||
results[0].StatusCode.ShouldBe(0x80340000u, "the skipped tag reads BadNodeIdUnknown");
|
||||
results[1].StatusCode.ShouldBe(0u, "the good neighbour is still served");
|
||||
}
|
||||
}
|
||||
@@ -224,6 +224,23 @@ public sealed class S7TsapAndPduTests
|
||||
(await S7DriverProbe.CheckDataAccessAsync(_ => throw missing, TestContext.Current.CancellationToken)).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The shapes a real read raises — S7.Net's read paths throw the CPU's item error as a BARE
|
||||
/// Exception (live-probed on the snap7 fixture). Before, the probe only matched PlcException, so a
|
||||
/// PUT/GET-disabled CPU got the generic "test read failed" text and a missing address failed the probe.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Probe_classifies_the_bare_read_path_shapes()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var denied = await S7DriverProbe.CheckDataAccessAsync(
|
||||
_ => throw new Exception("Received error from PLC: Accessing object not allowed."), ct);
|
||||
denied.ShouldNotBeNull().Message!.ShouldContain("PUT/GET");
|
||||
|
||||
(await S7DriverProbe.CheckDataAccessAsync(
|
||||
_ => throw new Exception("Received error from PLC: Object does not exist."), ct)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_reports_any_other_read_fault_as_a_failure()
|
||||
{
|
||||
|
||||
@@ -318,16 +318,9 @@ public sealed class S7ValueCodecTests
|
||||
[Fact]
|
||||
public async Task New_types_declared_as_arrays_or_off_byte_addresses_fail_init()
|
||||
{
|
||||
S7DriverOptions With(S7TagDefinition t) => new()
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
RawTags = S7RawTags.Entries(t),
|
||||
};
|
||||
using var arr = new S7Driver(With(Tag(S7DataType.Dtl) with { ArrayCount = 2 }), "a", new ConnectingFakeS7PlcFactory());
|
||||
await Should.ThrowAsync<NotSupportedException>(() => arr.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
using var word = new S7Driver(With(Tag(S7DataType.Tod, address: "DB1.DBW0")), "b", new ConnectingFakeS7PlcFactory());
|
||||
await Should.ThrowAsync<NotSupportedException>(() => word.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
// Refused PER TAG at every gate (they used to throw at init and fault the whole driver).
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(Tag(S7DataType.Dtl) with { Name = "DtlArr", ArrayCount = 2 }, "Dtl", "array");
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(Tag(S7DataType.Tod, address: "DB1.DBW0") with { Name = "TodWord" }, "Tod", "byte-addressed");
|
||||
}
|
||||
|
||||
// ---- TagConfig ----
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
using S7DataItem = global::S7.Net.Types.DataItem;
|
||||
using S7NetDataType = global::S7.Net.DataType;
|
||||
using VarType = global::S7.Net.VarType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A scriptable <see cref="IS7PlcFactory"/>: every connection it creates delegates its wire
|
||||
/// operations to the factory's handlers, so a test scripts the device once and observes every
|
||||
/// connection (including reopened ones). Counts wire operations so a test can prove what did
|
||||
/// NOT go to the wire without timing anything.
|
||||
/// </summary>
|
||||
internal sealed class ScriptedS7PlcFactory : IS7PlcFactory
|
||||
{
|
||||
private int _wireOps;
|
||||
|
||||
/// <summary>Every connection created, in order.</summary>
|
||||
public List<ScriptedS7Plc> Created { get; } = [];
|
||||
|
||||
/// <summary>Wire operations issued (reads, multi-var reads, writes; not opens or status probes).</summary>
|
||||
public int WireOps => Volatile.Read(ref _wireOps);
|
||||
|
||||
/// <summary>Hook run inside every <see cref="IS7Plc.OpenAsync"/>; the default opens immediately.</summary>
|
||||
public Func<int, CancellationToken, Task> Open { get; set; } = (_, _) => Task.CompletedTask;
|
||||
|
||||
/// <summary>Per-address string read; the default returns <c>(ushort)42</c>.</summary>
|
||||
public Func<string, CancellationToken, Task<object?>> Read { get; set; } = (_, _) => Task.FromResult<object?>((ushort)42);
|
||||
|
||||
/// <summary>Block read; the default returns <c>count</c> zero bytes.</summary>
|
||||
public Func<S7NetDataType, int, int, int, CancellationToken, Task<byte[]>> ReadBytes { get; set; }
|
||||
= (_, _, _, count, _) => Task.FromResult(new byte[count]);
|
||||
|
||||
/// <summary>Multi-var read; the default answers every item with a zero of its var type.</summary>
|
||||
public Func<List<S7DataItem>, CancellationToken, Task<IReadOnlyList<S7DataItem>>> ReadMulti { get; set; }
|
||||
= (items, _) =>
|
||||
{
|
||||
foreach (var i in items)
|
||||
i.Value = i.VarType switch
|
||||
{
|
||||
VarType.Bit => false,
|
||||
VarType.Byte => (byte)0,
|
||||
VarType.Word => (ushort)0,
|
||||
_ => (uint)0,
|
||||
};
|
||||
return Task.FromResult<IReadOnlyList<S7DataItem>>(items);
|
||||
};
|
||||
|
||||
/// <summary>Per-address string write; the default succeeds.</summary>
|
||||
public Func<string, object, CancellationToken, Task> Write { get; set; } = (_, _, _) => Task.CompletedTask;
|
||||
|
||||
/// <summary>Block write; the default succeeds.</summary>
|
||||
public Func<S7NetDataType, int, int, byte[], CancellationToken, Task> WriteBytes { get; set; }
|
||||
= (_, _, _, _, _) => Task.CompletedTask;
|
||||
|
||||
/// <summary>CPU-status probe; the default answers.</summary>
|
||||
public Func<CancellationToken, Task> ReadStatus { get; set; } = _ => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
|
||||
{
|
||||
lock (Created)
|
||||
{
|
||||
var plc = new ScriptedS7Plc(this, Created.Count);
|
||||
Created.Add(plc);
|
||||
return plc;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Counts one wire operation.</summary>
|
||||
internal void CountWireOp() => Interlocked.Increment(ref _wireOps);
|
||||
}
|
||||
|
||||
/// <summary>One connection created by <see cref="ScriptedS7PlcFactory"/>.</summary>
|
||||
internal sealed class ScriptedS7Plc(ScriptedS7PlcFactory factory, int index) : IS7Plc
|
||||
{
|
||||
/// <summary>Zero-based creation index.</summary>
|
||||
public int Index { get; } = index;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected { get; private set; }
|
||||
|
||||
/// <summary>True once disposed.</summary>
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public int MaxPduSize => 240;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task OpenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await factory.Open(Index, cancellationToken).ConfigureAwait(false);
|
||||
IsConnected = true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<object?> ReadAsync(string address, CancellationToken cancellationToken)
|
||||
{
|
||||
factory.CountWireOp();
|
||||
return factory.Read(address, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
factory.CountWireOp();
|
||||
return factory.ReadBytes(area, db, startByteAdr, count, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<S7DataItem>> ReadMultipleVarsAsync(List<S7DataItem> items, CancellationToken cancellationToken)
|
||||
{
|
||||
factory.CountWireOp();
|
||||
return factory.ReadMulti(items, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(string address, object value, CancellationToken cancellationToken)
|
||||
{
|
||||
factory.CountWireOp();
|
||||
return factory.Write(address, value, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken)
|
||||
{
|
||||
factory.CountWireOp();
|
||||
return factory.WriteBytes(area, db, startByteAdr, value, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ReadStatusAsync(CancellationToken cancellationToken) => factory.ReadStatus(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Close() => IsConnected = false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Disposed = true;
|
||||
IsConnected = false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user