using System.Collections.Concurrent; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests; /// /// Wire-level cases for the #739 High/Medium findings, against the live TwinCAT 3 XAR fixture /// (skipped without it, like ). Every case that writes restores what it /// changed: GVL_Primitives.vWord and vString are seeds the primitives theory reads. /// [Collection("TwinCATXar")] [Trait("Category", "Integration")] [Trait("Simulator", "TwinCAT-XAR")] public sealed class TwinCATRemediationGateTests(TwinCATXarFixture sim) { private string Host => $"ads://{sim.TargetNetId}:{sim.AmsPort}"; private TwinCATDriver NewDriver(string id, bool native, params TwinCATTagDefinition[] tags) => new(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(Host, "XAR-VM")], RawTags = TwinCATRawTags.Entries(tags), UseNativeNotifications = native, Timeout = TimeSpan.FromSeconds(5), Probe = new TwinCATProbeOptions { Enabled = false }, }, id); /// H11: a typo'd symbol in a native subscribe is isolated; the real counter keeps delivering. [TwinCATFact] public async Task Native_subscribe_isolates_an_unknown_symbol() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); await using var drv = NewDriver("tc3-gate-h11", native: true, new TwinCATTagDefinition("Counter", Host, "GVL_Fixture.nCounter", TwinCATDataType.DInt), new TwinCATTagDefinition("Typo", Host, "GVL_Fixture.nCounterTypo", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var events = new ConcurrentQueue(); drv.OnDataChange += (_, e) => events.Enqueue(e); var handle = await drv.SubscribeAsync(["Counter", "Typo"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); while (events.Count(e => e.FullReference == "Counter") < 2 && DateTime.UtcNow < deadline) await Task.Delay(50, TestContext.Current.CancellationToken); await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); events.Count(e => e.FullReference == "Counter" && e.Snapshot.StatusCode == 0u).ShouldBeGreaterThanOrEqualTo(2); events.First(e => e.FullReference == "Typo").Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown); drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a refused symbol is not a comms fault"); } /// H12: native pushes alone advance LastSuccessfulRead. [TwinCATFact] public async Task Native_pushes_advance_LastSuccessfulRead() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); await using var drv = NewDriver("tc3-gate-h12", native: true, new TwinCATTagDefinition("Counter", Host, "GVL_Fixture.nCounter", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var initStamp = drv.GetHealth().LastSuccessfulRead!.Value; var handle = await drv.SubscribeAsync(["Counter"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); await Task.Delay(1500, TestContext.Current.CancellationToken); await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp.AddMilliseconds(500)); } /// /// H18: a bit write on the 16-bit vWord (0xBEEF) flips exactly that bit at the WORD's own width /// (the old 32-bit UDInt RMW could not even address a WORD), and a bit beyond it is refused. /// [TwinCATFact] public async Task Bit_write_on_a_WORD_flips_one_bit_and_refuses_bit_16() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); await using var drv = NewDriver("tc3-gate-h18", native: false, new TwinCATTagDefinition("Word", Host, "GVL_Primitives.vWord", TwinCATDataType.UInt, Writable: false), new TwinCATTagDefinition("Bit4", Host, "GVL_Primitives.vWord.4", TwinCATDataType.Bool), new TwinCATTagDefinition("Bit16", Host, "GVL_Primitives.vWord.16", TwinCATDataType.Bool)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var before = Convert.ToUInt16((await drv.ReadAsync(["Word"], TestContext.Current.CancellationToken))[0].Value); var bit4WasSet = (before & 0x10) != 0; try { (await drv.WriteAsync([new WriteRequest("Bit4", !bit4WasSet)], TestContext.Current.CancellationToken))[0] .StatusCode.ShouldBe(0u); var after = Convert.ToUInt16((await drv.ReadAsync(["Word"], TestContext.Current.CancellationToken))[0].Value); after.ShouldBe((ushort)(before ^ 0x10), "only bit 4 may change"); (await drv.WriteAsync([new WriteRequest("Bit16", true)], TestContext.Current.CancellationToken))[0] .StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange); } finally { await drv.WriteAsync([new WriteRequest("Bit4", bit4WasSet)], CancellationToken.None); } Convert.ToUInt16((await drv.ReadAsync(["Word"], TestContext.Current.CancellationToken))[0].Value).ShouldBe(before); } /// U9: a whole-array write round-trips through the codec (CLR double[] converts to REAL elements). [TwinCATFact] public async Task Whole_array_write_round_trips() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); await using var drv = NewDriver("tc3-gate-u9-array", native: false, new TwinCATTagDefinition("Arr", Host, "GVL_Arrays.aReal1D", TwinCATDataType.Real, ArrayLength: 32)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var values = Enumerable.Range(0, 32).Select(i => i * 1.5 + 0.25).ToArray(); (await drv.WriteAsync([new WriteRequest("Arr", values)], TestContext.Current.CancellationToken))[0] .StatusCode.ShouldBe(0u); var read = (await drv.ReadAsync(["Arr"], TestContext.Current.CancellationToken))[0]; read.StatusCode.ShouldBe(0u); ((float[])read.Value!).ShouldBe(values.Select(v => (float)v).ToArray()); (await drv.WriteAsync([new WriteRequest("Arr", values[..31])], TestContext.Current.CancellationToken))[0] .StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange); } /// U9: a STRING write is NUL-padded at the PLC width (no stale tail) and an over-long one is refused. [TwinCATFact] public async Task String_write_round_trips_and_refuses_overflow() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); await using var drv = NewDriver("tc3-gate-u9-string", native: false, new TwinCATTagDefinition("S", Host, "GVL_Primitives.vString", TwinCATDataType.String)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var original = (string)(await drv.ReadAsync(["S"], TestContext.Current.CancellationToken))[0].Value!; try { (await drv.WriteAsync([new WriteRequest("S", "Grüße 40°")], TestContext.Current.CancellationToken))[0] .StatusCode.ShouldBe(0u); (await drv.ReadAsync(["S"], TestContext.Current.CancellationToken))[0].Value.ShouldBe("Grüße 40°"); (await drv.WriteAsync([new WriteRequest("S", new string('x', 81))], TestContext.Current.CancellationToken))[0] .StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange, "vString is STRING(80)"); } finally { await drv.WriteAsync([new WriteRequest("S", original)], CancellationToken.None); } (await drv.ReadAsync(["S"], TestContext.Current.CancellationToken))[0].Value.ShouldBe(original); } }