aabe027d10
L4 evidence over the real native runtime and a fake CIP target: a renamed tag that is the device's only lease holder is created once and Forward-Opened once across five polls, with no Forward Close, every poll BadNodeIdUnknown and the driver Healthy. Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
229 lines
11 KiB
C#
229 lines
11 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
|
|
|
/// <summary>
|
|
/// Review 07 H1 / L4: a status that proves the controller ANSWERED (NotFound, OutOfBounds, NotAllowed,
|
|
/// TooLarge, TooSmall, TypeMismatch, Unsupported) is a deterministic per-tag refusal, not a comms fault. It
|
|
/// keeps the tag runtime (no evict → no lease / Forward-Open churn), writes no Degraded, opens no device-wide
|
|
/// backoff window for siblings, warns once, and — since the device answered — advances LastSuccessfulRead.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class AbCipDeviceRefusalTests
|
|
{
|
|
private const string Device = "ab://10.0.0.5/1,0";
|
|
|
|
/// <summary>A fake whose reads/writes throw a chosen transport status (init succeeds unless told otherwise).</summary>
|
|
private sealed class RefusingTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
|
{
|
|
public AbStatus? ReadThrows { get; set; }
|
|
public AbStatus? WriteThrows { get; set; }
|
|
|
|
public override async Task ReadAsync(CancellationToken cancellationToken)
|
|
{
|
|
await base.ReadAsync(cancellationToken);
|
|
if (ReadThrows is { } s) throw new AbTransportException(s, s.ToString());
|
|
}
|
|
|
|
public override async Task WriteAsync(CancellationToken cancellationToken)
|
|
{
|
|
await base.WriteAsync(cancellationToken);
|
|
if (WriteThrows is { } s) throw new AbTransportException(s, s.ToString());
|
|
}
|
|
}
|
|
|
|
private static (AbCipDriver Driver, FakeAbCipTagFactory Factory) Build(
|
|
Func<AbCipTagCreateParams, FakeAbCipTag> make)
|
|
{
|
|
var factory = new FakeAbCipTagFactory { Customise = make };
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions(Device)],
|
|
RawTags = AbCipRawTags.From(
|
|
new AbCipTagDefinition("Bad", Device, "Arr[999]", AbCipDataType.DInt),
|
|
new AbCipTagDefinition("Good", Device, "Real1", AbCipDataType.DInt)),
|
|
Probe = new AbCipProbeOptions { Enabled = false },
|
|
MaxConcurrentReads = 1,
|
|
}, "abcip-refusal", factory);
|
|
return (drv, factory);
|
|
}
|
|
|
|
/// <summary>The classification itself: every answered refusal is reachable; transport failures are not.</summary>
|
|
/// <param name="status">The transport status.</param>
|
|
/// <param name="refusal">Whether it is a device refusal.</param>
|
|
[Theory]
|
|
[InlineData(AbStatus.NotFound, true)]
|
|
[InlineData(AbStatus.OutOfBounds, true)]
|
|
[InlineData(AbStatus.NotAllowed, true)]
|
|
[InlineData(AbStatus.TooLarge, true)]
|
|
[InlineData(AbStatus.TooSmall, true)]
|
|
[InlineData(AbStatus.TypeMismatch, true)]
|
|
[InlineData(AbStatus.Unsupported, true)]
|
|
[InlineData(AbStatus.Ok, false)]
|
|
[InlineData(AbStatus.Timeout, false)]
|
|
[InlineData(AbStatus.BadConnection, false)]
|
|
[InlineData(AbStatus.BadReply, false)]
|
|
[InlineData(AbStatus.Partial, false)]
|
|
[InlineData(AbStatus.Abort, false)]
|
|
public void Answered_statuses_are_device_refusals(AbStatus status, bool refusal)
|
|
{
|
|
AbCipRefusals.IsDeviceRefusal(status).ShouldBe(refusal);
|
|
AbCipDriverProbe.IsReachableStatus(status).ShouldBe(refusal || status == AbStatus.Ok);
|
|
}
|
|
|
|
/// <summary>A read the controller refuses keeps its runtime, stays Healthy, and advances LastSuccessfulRead.</summary>
|
|
/// <param name="status">The refusal the controller answers with.</param>
|
|
/// <param name="expected">The OPC UA status the tag reads.</param>
|
|
[Theory]
|
|
[InlineData(AbStatus.NotFound, AbCipStatusMapper.BadNodeIdUnknown)]
|
|
[InlineData(AbStatus.OutOfBounds, AbCipStatusMapper.BadOutOfRange)]
|
|
[InlineData(AbStatus.TooLarge, AbCipStatusMapper.BadOutOfRange)]
|
|
[InlineData(AbStatus.TooSmall, AbCipStatusMapper.BadOutOfRange)]
|
|
[InlineData(AbStatus.NotAllowed, AbCipStatusMapper.BadNotReadable)]
|
|
public async Task A_refused_read_keeps_the_runtime_and_writes_no_Degraded(AbStatus status, uint expected)
|
|
{
|
|
var (drv, factory) = Build(p => new RefusingTag(p) { ReadThrows = p.TagName == "Arr[999]" ? status : null, Value = 1 });
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
var before = drv.GetHealth().LastSuccessfulRead!.Value;
|
|
await Task.Delay(20);
|
|
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
var r = await drv.ReadAsync(["Bad"], CancellationToken.None);
|
|
r[0].StatusCode.ShouldBe(expected);
|
|
}
|
|
|
|
var bad = factory.Tags["Arr[999]"];
|
|
bad.InitializeCount.ShouldBe(1); // never evicted → never re-created
|
|
bad.Disposed.ShouldBeFalse();
|
|
bad.ReadCount.ShouldBe(3);
|
|
var health = drv.GetHealth();
|
|
health.State.ShouldBe(DriverState.Healthy);
|
|
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before); // the device answered
|
|
}
|
|
|
|
/// <summary>Positive control: a genuine transport failure on the same tag still evicts and degrades.</summary>
|
|
[Fact]
|
|
public async Task A_transport_failure_still_evicts_and_degrades()
|
|
{
|
|
var (drv, factory) = Build(p => new RefusingTag(p) { ReadThrows = p.TagName == "Arr[999]" ? AbStatus.Timeout : null });
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var r = await drv.ReadAsync(["Bad"], CancellationToken.None);
|
|
|
|
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout);
|
|
factory.Tags["Arr[999]"].Disposed.ShouldBeTrue();
|
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
}
|
|
|
|
/// <summary>A refusal returned as a status word (no throw) is classified the same way.</summary>
|
|
[Fact]
|
|
public async Task A_refusal_status_word_keeps_the_runtime()
|
|
{
|
|
var (drv, factory) = Build(p => new FakeAbCipTag(p) { Status = p.TagName == "Arr[999]" ? (int)AbStatus.OutOfBounds : 0, Value = 1 });
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.ReadAsync(["Bad"], CancellationToken.None);
|
|
var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
|
|
|
|
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
|
|
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
|
factory.Tags["Arr[999]"].InitializeCount.ShouldBe(1);
|
|
factory.Tags["Arr[999]"].Disposed.ShouldBeFalse();
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A cold create the controller refuses with OutOfBounds proves the session works: it must not open the
|
|
/// device-wide backoff window, so a sibling cold tag still connects and reads Good.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_refused_create_does_not_throttle_sibling_cold_creates()
|
|
{
|
|
var (drv, _) = Build(p => p.TagName == "Arr[999]"
|
|
? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = new AbTransportException(AbStatus.OutOfBounds, "OutOfBounds") }
|
|
: new FakeAbCipTag(p) { Value = 42 });
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
|
|
|
|
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
|
|
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
|
r[1].Value.ShouldBe(42);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
/// <summary>A write the controller refuses keeps the runtime and writes no health.</summary>
|
|
[Fact]
|
|
public async Task A_refused_write_keeps_the_runtime_and_writes_no_Degraded()
|
|
{
|
|
var factory = new FakeAbCipTagFactory { Customise = p => new RefusingTag(p) { WriteThrows = AbStatus.NotAllowed } };
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions(Device)],
|
|
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Ro", Device, "ReadOnlyTag", AbCipDataType.DInt)),
|
|
Probe = new AbCipProbeOptions { Enabled = false },
|
|
}, "abcip-refusal", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var w1 = await drv.WriteAsync([new WriteRequest("Ro", 5)], CancellationToken.None);
|
|
var w2 = await drv.WriteAsync([new WriteRequest("Ro", 6)], CancellationToken.None);
|
|
|
|
w1[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
|
w2[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
|
factory.Tags["ReadOnlyTag"].InitializeCount.ShouldBe(1);
|
|
factory.Tags["ReadOnlyTag"].Disposed.ShouldBeFalse();
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
/// <summary>
|
|
/// L4 end to end over the REAL native runtime + a private pool: a renamed tag (CIP 0x04) that is the device's
|
|
/// ONLY lease holder used to be evicted every poll — the last release, so Forward Close, then a throttled Forward
|
|
/// Open on the next poll. One runtime and one Forward Open across N polls and no Forward Close proves no churn.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_native_NotFound_tag_causes_no_runtime_or_connection_churn()
|
|
{
|
|
await using var target = new FakeCipTarget();
|
|
target.ConnectedHandler = req => Task.FromResult<byte[]?>(
|
|
req[0] == 0x52 ? [0xD2, 0x00, 0x04, 0x00] : target.ServiceReply(req));
|
|
var host = $"ab://127.0.0.1:{target.Port}/1,0";
|
|
var creates = 0;
|
|
var native = new NativeAbCipTagFactory(new DeviceSessionPool());
|
|
var factory = new CountingFactory(p => { Interlocked.Increment(ref creates); return native.Create(p); });
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)],
|
|
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Renamed", host, "Renamed", AbCipDataType.DInt)),
|
|
Probe = new AbCipProbeOptions { Enabled = false },
|
|
Timeout = TimeSpan.FromSeconds(5),
|
|
}, "abcip-l4-native", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
try
|
|
{
|
|
for (var n = 0; n < 5; n++)
|
|
(await drv.ReadAsync(["Renamed"], CancellationToken.None))[0].StatusCode
|
|
.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
|
|
|
Volatile.Read(ref creates).ShouldBe(1);
|
|
target.ForwardOpens.ShouldBe(1);
|
|
target.ForwardCloses.ShouldBe(0);
|
|
target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(5); // positive control: every poll hit the wire
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
finally
|
|
{
|
|
await drv.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
private sealed class CountingFactory(Func<AbCipTagCreateParams, IAbCipTagRuntime> create) : IAbCipTagFactory
|
|
{
|
|
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) => create(createParams);
|
|
}
|
|
}
|