fix(ablegacy): deterministic NotSupported refusals are BadNotSupported with no health write
Review 07 H16: an array of String/Timer/Counter/Control passed the mapper, so every poll created a handle (one init read), the decode threw NotSupportedException, the generic catch evicted the handle and latched Degraded. FromTagConfig now rejects that combination (the skip carries the reason, Inspect/deploy gate and the AdminUI model state it), and a runtime NotSupportedException on read or write is BadNotSupported: handle kept, no health write, one Warning per tag and operation. Write refusals (native PLC-5 and status-bit writes, libplctag translated addresses) now warn once per tag too. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
+136
@@ -0,0 +1,136 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 H16: an array of String/Timer/Counter/Control was accepted by the mapper, so every poll created a
|
||||
/// handle (one init read), threw <see cref="NotSupportedException"/> from the decode, disposed the handle and
|
||||
/// latched Degraded. A deterministic refusal is now rejected at the mapper; a runtime
|
||||
/// <see cref="NotSupportedException"/> that still occurs is per-tag <c>BadNotSupported</c>, keeps its handle,
|
||||
/// warns once and leaves driver health alone.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyDeterministicRefusalTests
|
||||
{
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
|
||||
[Theory]
|
||||
[InlineData("String", "ST9:0")]
|
||||
[InlineData("TimerElement", "T4:0")]
|
||||
[InlineData("CounterElement", "C5:0")]
|
||||
[InlineData("ControlElement", "R6:0")]
|
||||
public void Mapper_rejects_arrays_of_structured_element_types(string dataType, string address)
|
||||
{
|
||||
var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}""";
|
||||
|
||||
AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out _).ShouldBeFalse();
|
||||
AbLegacyTagDefinitionFactory.Inspect(json).ShouldContain(w => w.Contains("not supported as an array"));
|
||||
// Positive control: the same type as a SCALAR is still accepted.
|
||||
AbLegacyTagDefinitionFactory.FromTagConfig(
|
||||
$$"""{"address":"{{address}}","dataType":"{{dataType}}"}""", "p", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Int", "N7:0")]
|
||||
[InlineData("Float", "F8:0")]
|
||||
[InlineData("Long", "L9:0")]
|
||||
[InlineData("Bit", "B3:0")]
|
||||
public void Mapper_keeps_arrays_of_flat_element_types(string dataType, string address)
|
||||
{
|
||||
var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}""";
|
||||
AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out var def).ShouldBeTrue();
|
||||
def.ArrayLength.ShouldBe(4);
|
||||
AbLegacyTagDefinitionFactory.Inspect(json).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejected_array_tag_is_skipped_with_its_reason_and_creates_no_runtime()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags =
|
||||
[
|
||||
new RawTagEntry("Strs", """{"address":"ST9:0","dataType":"String","isArray":true,"arrayLength":3}""", false, Host),
|
||||
new RawTagEntry("Good", """{"address":"N7:0","dataType":"Int"}""", false, Host),
|
||||
],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-h16", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["Strs", "Good"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control
|
||||
factory.Tags.Keys.ShouldNotContain("ST9:0");
|
||||
var skipped = drv.GetSkippedTags().ShouldNotBeNull();
|
||||
skipped.Sample.ShouldContain(s => s.RawPath == "Strs" && s.Reason.Contains("not supported as an array"));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private sealed class RefusingDecodeTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public override object? DecodeArray(AbLegacyDataType type, int count) =>
|
||||
throw new NotSupportedException("decode refused");
|
||||
|
||||
public override void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) =>
|
||||
throw new NotSupportedException("encode refused");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Runtime_NotSupported_on_read_is_BadNotSupported_keeps_handle_no_health_warns_once()
|
||||
{
|
||||
var logger = new AbLegacyTestLogger();
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => p.TagName == "N7:0" ? new RefusingDecodeTag(p) : new FakeAbLegacyTag(p) { Value = 7 },
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags = AbLegacyRawTags.Entries(
|
||||
new AbLegacyTagDefinition("Arr", Host, "N7:0", AbLegacyDataType.Int, ArrayLength: 4),
|
||||
new AbLegacyTagDefinition("Good", Host, "N9:0", AbLegacyDataType.Int)),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-h16", factory, logger);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var first = await drv.ReadAsync(["Arr", "Good"], CancellationToken.None);
|
||||
var second = await drv.ReadAsync(["Arr", "Good"], CancellationToken.None);
|
||||
|
||||
first[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
second[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
first[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control: the refusal is per tag
|
||||
var tag = factory.Tags["N7:0"];
|
||||
tag.InitializeCount.ShouldBe(1); // handle kept, not churned
|
||||
tag.Disposed.ShouldBeFalse();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
logger.Warnings.Count(w => w.Contains("Arr") && w.Contains("decode refused")).ShouldBe(1);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Runtime_NotSupported_on_write_is_BadNotSupported_with_no_health_write()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new RefusingDecodeTag(p) { Value = 1 } };
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-h16", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
|
||||
var w = await drv.WriteAsync([new WriteRequest("X", 5)], CancellationToken.None);
|
||||
|
||||
w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
factory.Tags["N7:0"].Disposed.ShouldBeFalse();
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,20 @@ public sealed class AbLegacyTagConfigModelTests
|
||||
m.Validate().ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Review 07 H16: an array of a structured element type is refused with the reader's own reason.</summary>
|
||||
[Theory]
|
||||
[InlineData("String", "ST9:0")]
|
||||
[InlineData("TimerElement", "T4:0")]
|
||||
public void Validate_refuses_an_array_of_a_structured_element_type(string dataType, string address)
|
||||
{
|
||||
var m = AbLegacyTagConfigModel.FromJson(
|
||||
$$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}""");
|
||||
|
||||
m.Validate().ShouldNotBeNull().ShouldContain("not supported as an array");
|
||||
// Positive control: the same type as a scalar validates.
|
||||
AbLegacyTagConfigModel.FromJson($$"""{"address":"{{address}}","dataType":"{{dataType}}"}""").Validate().ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_null_for_scalar_tag_regardless_of_no_array_length()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user