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:
+146
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user