using System.Collections.Concurrent;
using System.Diagnostics;
using Shouldly;
using TwinCAT.TypeSystem;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
///
/// #739 review fix round 1: the PLC-not-in-RUN health ruling, refused-registration retry backoff,
/// per-device worst-of health, RMW gates surviving teardown, a cancellable shutdown, the real
/// STRING / array encode path through the client planning helpers, and REAL range checks.
///
[Trait("Category", "Unit")]
public sealed class TwinCATReviewFixRound1Tests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private const string HostB = "ads://5.23.91.24.1.1:851";
private static TwinCATProbeOptions FastProbe => new()
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(30),
Timeout = TimeSpan.FromMilliseconds(100),
};
private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, TwinCATProbeOptions probe, bool native,
params TwinCATTagDefinition[] tags) =>
new(new TwinCATDriverOptions
{
Devices = [.. tags.Select(t => t.DeviceHostAddress).Append(Host).Distinct().Select(h => new TwinCATDeviceOptions(h))],
RawTags = TwinCATRawTags.Entries(tags),
Probe = probe,
UseNativeNotifications = native,
}, "drv-1", factory);
private static async Task WaitForAsync(Func condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(10);
}
// ---- IMPORTANT: a PLC not in RUN leaves the driver Degraded ----
///
/// A probe that answers with ADS state Stop writes Degraded "PLC in Stop" and still advances
/// LastSuccessfulRead (the device answered); back in Run the driver is Healthy (positive control).
///
[Fact]
public async Task A_PLC_not_in_Run_is_Degraded_and_Run_restores_Healthy()
{
var fake = new FakeTwinCATClient { AdsStateText = "Stop" };
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, FastProbe, native: false,
new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var initStamp = drv.GetHealth().LastSuccessfulRead!.Value;
await WaitForAsync(() => drv.GetHealth().State == DriverState.Degraded, TimeSpan.FromSeconds(3));
var stopped = drv.GetHealth();
stopped.State.ShouldBe(DriverState.Degraded);
stopped.LastError.ShouldNotBeNull().ShouldContain("PLC in Stop");
await WaitForAsync(() => drv.GetHealth().LastSuccessfulRead > initStamp, TimeSpan.FromSeconds(3));
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp);
// A Good read while the PLC is stopped must not paper over it.
_ = await drv.ReadAsync(["Speed"], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
fake.AdsStateText = "Run";
await WaitForAsync(() => drv.GetHealth().State == DriverState.Healthy, TimeSpan.FromSeconds(3));
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
// ---- MINOR 1: refused dead registrations back off; a class change republishes ----
///
/// A REFUSED dead registration is not re-attempted every probe tick; a comms-failed one is (positive
/// control: it recovers). A symbol-version-changed signal re-arms the refused one at once.
///
[Fact]
public async Task Refused_registrations_back_off_while_comms_failures_retry_each_tick()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.T"] = new TwinCATTypeMismatchException("MAIN.T", "declared DInt, PLC STRING");
fake.AddNotificationErrors["MAIN.C"] = new InvalidOperationException("transient");
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, FastProbe, native: true,
new TwinCATTagDefinition("T", Host, "MAIN.T", TwinCATDataType.DInt),
new TwinCATTagDefinition("C", Host, "MAIN.C", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.SubscribeAsync(["T", "C"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
await WaitForAsync(() => fake.AttemptsFor("MAIN.C") >= 3, TimeSpan.FromSeconds(3));
fake.AttemptsFor("MAIN.C").ShouldBeGreaterThanOrEqualTo(3); // comms class: every tick
fake.AttemptsFor("MAIN.T").ShouldBe(1); // refusal: backed off
fake.AddNotificationErrors.TryRemove("MAIN.C", out _);
await WaitForAsync(() => fake.Notifications.Any(n => n.SymbolPath == "MAIN.C"), TimeSpan.FromSeconds(3));
fake.Notifications.ShouldContain(n => n.SymbolPath == "MAIN.C");
fake.FireSymbolVersionChanged();
await WaitForAsync(() => fake.AttemptsFor("MAIN.T") >= 2, TimeSpan.FromSeconds(3));
fake.AttemptsFor("MAIN.T").ShouldBe(2);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
/// When a retry fails with a different failure class the tag's status is republished, once.
[Fact]
public async Task A_retry_whose_failure_class_changes_republishes_the_status()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.X"] = new InvalidOperationException("router hiccup");
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, FastProbe, native: true,
new TwinCATTagDefinition("X", Host, "MAIN.X", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
events.ShouldHaveSingleItem().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
await WaitForAsync(() => fake.AttemptsFor("MAIN.X") >= 3, TimeSpan.FromSeconds(3));
events.Count.ShouldBe(1, "a repeat of the same failure class publishes nothing");
fake.AddNotificationErrors["MAIN.X"] = new TwinCATNotificationRefusedException(
"MAIN.X", "symbol gone", TwinCATStatusMapper.BadNodeIdUnknown);
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(3));
events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
// ---- MINOR 2: per-device worst-of health ----
///
/// A comms fault on device B is not cleared by device A answering; B answering clears it
/// (positive control).
///
[Fact]
public async Task One_devices_answer_does_not_clear_another_devices_comms_fault()
{
var clients = new ConcurrentDictionary();
var build = 0;
var factory = new FakeTwinCATClientFactory
{
Customise = () =>
{
var c = new FakeTwinCATClient();
clients[Interlocked.Increment(ref build)] = c;
return c;
},
};
var drv = NewDriver(factory, new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("A", Host, "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", HostB, "MAIN.B", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.ReadAsync(["A"], TestContext.Current.CancellationToken); // client 1 = device A
_ = await drv.ReadAsync(["B"], TestContext.Current.CancellationToken); // client 2 = device B
clients[2].ReadStatuses["MAIN.B"] = TwinCATStatusMapper.BadTimeout;
_ = await drv.ReadAsync(["B"], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
_ = await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "device A answering says nothing about device B");
clients[2].ReadStatuses.Remove("MAIN.B");
_ = await drv.ReadAsync(["B"], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
// ---- MINOR 3: RMW gates are kept across generations ----
/// The per-parent RMW gate survives a teardown (a write still in flight keeps the same gate).
[Fact]
public async Task Bit_RMW_gates_survive_a_shutdown()
{
var fake = new FakeTwinCATClient { ParentWords = { ["MAIN.Flags"] = [0, 0] } };
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("Bit", Host, "MAIN.Flags.1", TwinCATDataType.Bool));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.WriteAsync([new WriteRequest("Bit", true)], TestContext.Current.CancellationToken);
var gate = drv.BitRmwGateForTest(Host, "MAIN.Flags").ShouldNotBeNull();
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.WriteAsync([new WriteRequest("Bit", false)], TestContext.Current.CancellationToken);
drv.BitRmwGateForTest(Host, "MAIN.Flags").ShouldBeSameAs(gate);
}
// ---- MINOR 4: ShutdownAsync honours its token ----
///
/// A cancelled ShutdownAsync stops waiting on a probe wedged in connect and throws
/// OperationCanceledException; the teardown itself still happened (tables emptied).
///
[Fact]
public async Task Shutdown_honours_its_cancellation_token()
{
var wedge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { BlockConnect = wedge } };
var drv = NewDriver(factory, FastProbe, native: false);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
var sw = Stopwatch.StartNew();
await Should.ThrowAsync(() => drv.ShutdownAsync(cts.Token));
sw.Elapsed.ShouldBeLessThan(TwinCATDriver.ProbeJoinTimeout, "the wait ended on the token, not the join bound");
drv.DeviceCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
// The background teardown finishes once the wedge releases, and disposes the client the wedged
// connect produced (no dangling teardown left behind the test).
var teardown = drv.LastTeardownForTest.ShouldNotBeNull();
wedge.SetResult();
await teardown.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
await WaitForAsync(() => factory.Clients[0].DisposeCount > 0, TimeSpan.FromSeconds(3));
factory.Clients[0].DisposeCount.ShouldBeGreaterThan(0);
}
// ---- MINOR 7: the real STRING / array encode path through the client planning helpers ----
///
/// A STRING write through the driver lands as NUL-padded Windows-1252 bytes at the PLC symbol's width;
/// an over-long value is BadOutOfRange and a WSTRING symbol under a String tag BadTypeMismatch.
///
[Fact]
public async Task String_writes_go_through_the_client_encode_plan()
{
var fake = new FakeTwinCATClient();
fake.SymbolShapes["MAIN.Name"] = TwinCATPlcSymbolShape.Classify("STRING(4)", DataTypeCategory.String, 5);
fake.SymbolShapes["MAIN.Wide"] = TwinCATPlcSymbolShape.Classify("WSTRING(4)", DataTypeCategory.String, 10);
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("Name", Host, "MAIN.Name", TwinCATDataType.String),
new TwinCATTagDefinition("Wide", Host, "MAIN.Wide", TwinCATDataType.String));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.WriteAsync(
[
new WriteRequest("Name", "äb"),
new WriteRequest("Name", "toolong"),
new WriteRequest("Wide", "x"),
], TestContext.Current.CancellationToken);
results.Select(r => r.StatusCode).ShouldBe(
[TwinCATStatusMapper.Good, TwinCATStatusMapper.BadOutOfRange, TwinCATStatusMapper.BadTypeMismatch]);
fake.RawWrites["MAIN.Name"].ShouldBe(new byte[] { 0xE4, (byte)'b', 0, 0, 0 });
fake.RawWrites.ShouldNotContainKey("MAIN.Wide");
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// An array write through the driver lands as the codec's bytes at the PLC element stride.
[Fact]
public async Task Array_writes_go_through_the_client_encode_plan()
{
var fake = new FakeTwinCATClient();
var element = TwinCATPlcSymbolShape.Classify("INT", DataTypeCategory.Primitive, 2);
fake.SymbolShapes["MAIN.Buf"] = TwinCATPlcSymbolShape.Classify("ARRAY [0..3] OF INT", DataTypeCategory.Array, 8);
fake.ArrayShapes["MAIN.Buf"] = new TwinCATPlcArrayShape([4], 8, element);
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("Buf", Host, "MAIN.Buf", TwinCATDataType.Int, ArrayLength: 2),
new TwinCATTagDefinition("BufAsReal", Host, "MAIN.Buf", TwinCATDataType.Real, ArrayLength: 2));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.WriteAsync(
[
new WriteRequest("Buf", new[] { 1, -2 }),
new WriteRequest("BufAsReal", new[] { 1.0, 2.0 }),
], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
fake.RawWrites["MAIN.Buf"].ShouldBe(new byte[] { 0x01, 0x00, 0xFE, 0xFF }); // leading sub-range only
results[1].StatusCode.ShouldBe(TwinCATStatusMapper.BadTypeMismatch);
}
// ---- MINOR 8: REAL / LREAL range ----
/// A REAL (scalar or array element) beyond float range is BadOutOfRange, never written as Infinity.
[Fact]
public async Task Out_of_range_REAL_values_are_BadOutOfRange()
{
Should.Throw(() => AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, 1e300));
Should.Throw(() => AdsTwinCATClient.ConvertForWrite(TwinCATDataType.LReal, "1e400"));
AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, 3.5).ShouldBe(3.5f);
AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, float.PositiveInfinity).ShouldBe(float.PositiveInfinity);
float.IsNaN((float)AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, double.NaN)).ShouldBeTrue();
var fake = new FakeTwinCATClient();
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("Arr", Host, "MAIN.Arr", TwinCATDataType.Real, ArrayLength: 2));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var result = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1.0, 1e300 })], TestContext.Current.CancellationToken);
result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
fake.ArrayWriteLog.ShouldBeEmpty();
}
}