f087f01d2b
H1/L4: one classification (AbCipRefusals.IsDeviceRefusal) of the statuses
that prove the controller answered - NotFound, OutOfBounds, NotAllowed,
TooLarge, TooSmall, TypeMismatch, Unsupported - shared by the data path, the
Test-Connect probe (IsReachableStatus widened) and the native runtime.
- A refused read/write (thrown or as a status word) is per-tag Bad with a
once-per-tag Warning, keeps the runtime (no evict, so no lease/Forward-Open
churn for a sole lease holder), writes no Degraded and, since the device
answered, advances LastSuccessfulRead without touching the state.
- A refused cold create records the backoff as reachable, so siblings are no
longer throttled for up to 30 s by one Arr[999].
- The native runtime completes InitializeAsync on any refusal (it used to on
TypeMismatch/Unsupported only) and reports it on every later read.
- A refused READ of NotAllowed reads BadNotReadable, not BadNotWritable.
- Test Connect names the refusal ("probe tag refused: OutOfBounds").
0x1C/0x20/0xFF-0x2104 still fold into BadReply inside Driver.Ab.Transport
(Lane K follow-up); they remain comms faults until mapped there.
Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
182 lines
8.5 KiB
C#
182 lines
8.5 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
|
|
|
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);
|
|
}
|
|
}
|