6b863cd524
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
173 lines
7.8 KiB
C#
173 lines
7.8 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|