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(); } }