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
This commit is contained in:
Joseph Doherty
2026-10-22 14:30:05 -04:00
parent 6201bafea4
commit 8de5947e2d
@@ -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;
/// <summary>
/// Wire-level cases for the #739 High/Medium findings, against the live TwinCAT 3 XAR fixture
/// (skipped without it, like <see cref="TwinCAT3SmokeTests"/>). Every case that writes restores what it
/// changed: <c>GVL_Primitives.vWord</c> and <c>vString</c> are seeds the primitives theory reads.
/// </summary>
[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);
/// <summary>H11: a typo'd symbol in a native subscribe is isolated; the real counter keeps delivering.</summary>
[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<DataChangeEventArgs>();
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");
}
/// <summary>H12: native pushes alone advance LastSuccessfulRead.</summary>
[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));
}
/// <summary>
/// H18: a bit write on the 16-bit <c>vWord</c> (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.
/// </summary>
[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);
}
/// <summary>U9: a whole-array write round-trips through the codec (CLR double[] converts to REAL elements).</summary>
[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);
}
/// <summary>U9: a STRING write is NUL-padded at the PLC width (no stale tail) and an over-long one is refused.</summary>
[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);
}
}