test(abcip): ab_server cases for the probe blackout, refusals, array writes

Integration cases (both transports, skipped when the fixture is unreachable):
- H10: a typo'd probe tag leaves the host Running and a cold data tag created
  after many probe ticks still reads Good;
- H1/L4: a missing tag reads BadNodeIdUnknown every poll beside a Good sibling,
  the driver stays Healthy and LastSuccessfulRead advances even when only the
  refused tag is read;
- U10 (AB_SERVER_ALLOW_WRITES=1): an int[16] written to TestArray (DINT[16])
  reads back element for element through a fresh driver; a 15-element write is
  BadOutOfRange.

Not run here: the 10.100.0.35:44818 fixture was down during this lane (Wave 4).

Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 14:37:18 -04:00
parent 8a941de4cd
commit 6b863cd524
@@ -0,0 +1,172 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
/// <summary>
/// Review 07 (Gitea #737) against a running <c>ab_server</c> on both transports: a controller refusal is a per-tag
/// config error, not a comms fault (H10 probe, H1/L4 data path), and a whole-array write round-trips (U10).
/// Skipped when the fixture is unreachable (<see cref="AbServerTheoryAttribute"/>); the array write additionally
/// needs <c>AB_SERVER_ALLOW_WRITES=1</c> (<see cref="IntegrationWriteGate"/>).
/// </summary>
[Trait("Category", "Integration")]
[Trait("Requires", "AbServer")]
public sealed class AbCipRefusalLiveTests
{
private static string Device(AbServerFixture fixture) => $"ab://{fixture.Host}:{fixture.Port}/1,0";
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout, string what)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException(what);
await Task.Delay(50);
}
}
/// <summary>
/// H10: a typo'd probe tag on a reachable controller leaves the host Running, and a COLD data tag created
/// after many probe ticks still connects (the backoff window was never opened by the probe's NotFound).
/// </summary>
/// <param name="transport">The transport stack the driver runs on.</param>
[AbServerTheory]
[MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))]
public async Task A_typod_probe_tag_does_not_black_out_a_reachable_device(AbCipTransport transport)
{
var device = Device(new AbServerFixture());
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(device, AbCipPlcFamily.ControlLogix)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Counter", device, "TestDINT", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions
{
Enabled = true,
ProbeTagPath = "NoSuchProbeTag_xyz",
Interval = TimeSpan.FromMilliseconds(200),
Timeout = TimeSpan.FromSeconds(2),
},
Timeout = TimeSpan.FromSeconds(5),
Transport = transport,
}, $"drv-h10-{transport}");
await drv.InitializeAsync("{}", CancellationToken.None);
try
{
await WaitUntilAsync(() => drv.GetHostStatuses().Single().State == HostState.Running,
TimeSpan.FromSeconds(10), "the host never went Running on a refused probe tag");
await Task.Delay(1_000); // several probe ticks — each one used to record a Forward-Open failure
var r = await drv.ReadAsync(["Counter"], CancellationToken.None); // a cold create
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
}
finally
{
await drv.DisposeAsync();
}
}
/// <summary>
/// H1/L4: a tag the controller does not have reads BadNodeIdUnknown every poll while a sibling reads Good, and
/// the driver stays Healthy with LastSuccessfulRead advancing (it used to latch Degraded and, with no other
/// tag, trip the comms-loss watchdog against a reachable controller).
/// </summary>
/// <param name="transport">The transport stack the driver runs on.</param>
[AbServerTheory]
[MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))]
public async Task A_missing_tag_is_per_tag_Bad_and_never_degrades_the_driver(AbCipTransport transport)
{
var device = Device(new AbServerFixture());
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(device, AbCipPlcFamily.ControlLogix)],
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Typo", device, "NoSuchTag_xyz", AbCipDataType.DInt),
new AbCipTagDefinition("Counter", device, "TestDINT", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
Timeout = TimeSpan.FromSeconds(5),
Transport = transport,
}, $"drv-h1-{transport}");
await drv.InitializeAsync("{}", CancellationToken.None);
try
{
DateTime? last = null;
for (var i = 0; i < 3; i++)
{
var r = await drv.ReadAsync(["Typo", "Counter"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Healthy);
if (last is { } previous) health.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(previous);
last = health.LastSuccessfulRead;
}
// Only the refused tag: still Healthy, still answering (the watchdog conjunction can never trip).
var before = drv.GetHealth().LastSuccessfulRead!.Value;
await Task.Delay(20);
(await drv.ReadAsync(["Typo"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before);
}
finally
{
await drv.DisposeAsync();
}
}
/// <summary>
/// U10: a whole <c>int[]</c> written to the 16-element <c>TestArray</c> (DINT[16]) reads back element for element
/// through a fresh driver; a wrong-length write is refused BadOutOfRange and changes nothing.
/// </summary>
/// <param name="transport">The transport stack the driver runs on.</param>
[AbServerTheory]
[MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))]
public async Task A_whole_array_write_round_trips(AbCipTransport transport)
{
IntegrationWriteGate.SkipUnlessWritesAllowed();
var device = Device(new AbServerFixture());
var seed = Random.Shared.Next(1_000, 1_000_000);
var values = Enumerable.Range(0, 16).Select(i => seed + i * 7).ToArray();
AbCipDriver Make(string id) => new(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(device, AbCipPlcFamily.ControlLogix)],
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Arr", device, "TestArray", AbCipDataType.DInt, ElementCount: 16, IsArray: true)),
Probe = new AbCipProbeOptions { Enabled = false },
Timeout = TimeSpan.FromSeconds(5),
Transport = transport,
}, $"drv-u10-{id}-{transport}");
var writer = Make("writer");
await writer.InitializeAsync("{}", CancellationToken.None);
try
{
var w = await writer.WriteAsync([new WriteRequest("Arr", values)], CancellationToken.None);
w[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
var bad = await writer.WriteAsync([new WriteRequest("Arr", values.Take(15).ToArray())], CancellationToken.None);
bad[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
writer.GetHealth().State.ShouldBe(DriverState.Healthy);
}
finally
{
await writer.DisposeAsync();
}
var reader = Make("reader");
await reader.InitializeAsync("{}", CancellationToken.None);
try
{
var r = await reader.ReadAsync(["Arr"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
r[0].Value.ShouldBe(values);
}
finally
{
await reader.DisposeAsync();
}
}
}