459cbb444e
I1: the U11 Bits[n] -> Bits[n/32] rewrite was unconditional. The native runtime now keeps the original element path until the first reply settles the layout: 0xD3 confirms DWORD addressing (bit n%32); any other reply type (e.g. a 0xC1 per-element BOOL array) or a refusal of the DWORD index falls back to Bits[n] and re-reads, and a write settles the layout by a read first, so it can never decode or write element n/32 of an element-addressed array. I2: libplctag ErrorNoMatch/ErrorBadDevice no longer fold into NotFound (they now read as BadConnection: comms, Degraded, LastSuccessfulRead preserved). ErrorNotImplemented - and the native runtime's own pre-send refusals - raise AbCipLocalRefusalException: BadNotSupported, no health, LastSuccessfulRead NOT advanced, no backoff record, not reachable for the probe or Test Connect. M1: the bit RMW's parent read/write status words follow the health contract (refusal: per-tag Bad + Warning; comms: evict the parent + Degraded). M2: device Unsupported read refusals return Refused (advance LastSuccessfulRead); device write refusals call RecordWriteAnswered (not the pre-send native type gate). M3: a good write clears a Degraded a write comms fault recorded (a read-side Degraded is still left for the read path). M4: a superseded init throws AbCipInitializationSupersededException (not an OCE); ShutdownAsync stamps Unknown only if no newer init published meanwhile. M5: the sync-Dispose timing assert is 2 s against a 4 s probe read; the unencodable-array test asserts health. Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
299 lines
14 KiB
C#
299 lines
14 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #737 I2: a LOCAL refusal (libplctag ErrorNotImplemented — no I/O) is per-tag BadNotSupported, keeps health
|
|
/// state, and does NOT advance LastSuccessfulRead (nothing proves the device answered). Positive control: the
|
|
/// device's own Unsupported refusal does advance it (M2).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_local_refusal_does_not_advance_LastSuccessfulRead_but_a_device_one_does()
|
|
{
|
|
var (drv, _) = Build(p => p.TagName == "Arr[999]"
|
|
? new LocalRefusingTag(p)
|
|
: new RefusingTag(p) { ReadThrows = AbStatus.Unsupported });
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
var before = drv.GetHealth().LastSuccessfulRead!.Value;
|
|
await Task.Delay(20);
|
|
|
|
(await drv.ReadAsync(["Bad"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
|
|
drv.GetHealth().LastSuccessfulRead.ShouldBe(before);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
|
|
(await drv.ReadAsync(["Good"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
|
|
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before);
|
|
}
|
|
|
|
/// <summary>#737 I2: a local refusal at create opens no backoff — a sibling cold create still connects.</summary>
|
|
[Fact]
|
|
public async Task A_local_refusal_at_create_does_not_throttle_or_count_as_answered()
|
|
{
|
|
var (drv, _) = Build(p => p.TagName == "Arr[999]"
|
|
? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = new AbCipLocalRefusalException("ErrorNotImplemented") }
|
|
: new FakeAbCipTag(p) { Value = 3 });
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
|
|
|
|
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
|
|
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
/// <summary>M2: a write the device refuses proves it answered — LastSuccessfulRead advances.</summary>
|
|
[Fact]
|
|
public async Task A_refused_write_advances_LastSuccessfulRead()
|
|
{
|
|
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 before = drv.GetHealth().LastSuccessfulRead!.Value;
|
|
await Task.Delay(20);
|
|
|
|
(await drv.WriteAsync([new WriteRequest("Ro", 5)], CancellationToken.None))[0].StatusCode
|
|
.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
|
|
|
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
private sealed class LocalRefusingTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
|
{
|
|
public override async Task ReadAsync(CancellationToken cancellationToken)
|
|
{
|
|
await base.ReadAsync(cancellationToken);
|
|
throw new AbCipLocalRefusalException("ErrorNotImplemented");
|
|
}
|
|
}
|
|
}
|