From 8de5947e2d48d42799ac6b610bf9eb47817225c9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:30:05 -0400 Subject: [PATCH] test(twincat): XAR-gated wire cases for H11/H12/H18/U9 Native-subscribe isolation of an unknown symbol, LastSuccessfulRead advance from pushes alone, a WORD bit write flipping exactly one bit (bit 16 refused), a whole-array REAL write round-trip (length mismatch refused) and a STRING(80) write round-trip (overflow refused). Every writing case restores the seed it touched. Skipped without the TwinCAT XAR fixture. Refs #739 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../TwinCATRemediationGateTests.cs | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATRemediationGateTests.cs diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATRemediationGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATRemediationGateTests.cs new file mode 100644 index 00000000..c592495b --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATRemediationGateTests.cs @@ -0,0 +1,146 @@ +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); + } +}