Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeterministicRefusalTests.cs
T
Joseph Doherty 8b50b43048 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
2026-10-22 14:20:17 -04:00

137 lines
6.6 KiB
C#

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);
}
}