1d7a4b7a53
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
232 lines
11 KiB
C#
232 lines
11 KiB
C#
using libplctag;
|
|
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.AbLegacy.Tests;
|
|
|
|
/// <summary>
|
|
/// Pins BOTH sides of the reply-classification line (review 07 r0 I1 + r1 N1).
|
|
/// <list type="bullet">
|
|
/// <item><b>No reply</b> (Timeout / BadConnection / Abort / Pending / unknown) — comms fault: evict, Degraded,
|
|
/// counts toward fail-fast.</item>
|
|
/// <item><b>Answered</b> (an allow-list: NotFound / NotAllowed / OutOfBounds / TooLarge / TooSmall / TypeMismatch /
|
|
/// DeviceStateConflict) — proves reachability: per-tag Bad, Healthy, <c>LastSuccessfulRead</c> advances.</item>
|
|
/// <item><b>Opaque</b> (BadReply / Partial — incl. libplctag's <c>ERR_REMOTE_ERR</c>, which folds a PLC refusal
|
|
/// and a routing hop's "end device gone" together) — neither: per-tag Bad, never fail-fast, never blacks out
|
|
/// coalesced neighbours, but NEVER advances <c>LastSuccessfulRead</c>; an opaque-only device degrades so the
|
|
/// comms-loss watchdog can reach a dead PLC behind a bridge.</item>
|
|
/// </list>
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class AbLegacyAnsweredStatusTests
|
|
{
|
|
private const string Host = "ab://10.0.0.5/1,0";
|
|
|
|
/// <summary>What the libplctag runtime's GetStatus() reports for ERR_REMOTE_ERR (through the real translation).</summary>
|
|
private static readonly int RemoteErr = (int)LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorRemoteErr);
|
|
|
|
private static AbLegacyDriver NewDriver(FakeAbLegacyTagFactory factory, bool coalesce, params AbLegacyTagDefinition[] tags) =>
|
|
new(new AbLegacyDriverOptions
|
|
{
|
|
Devices = [new AbLegacyDeviceOptions(Host)],
|
|
RawTags = AbLegacyRawTags.Entries(tags),
|
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
|
CoalesceReads = coalesce,
|
|
}, "drv-i1", factory);
|
|
|
|
// ---- the classification table ----
|
|
|
|
[Theory]
|
|
[InlineData(AbStatus.NotFound)]
|
|
[InlineData(AbStatus.NotAllowed)]
|
|
[InlineData(AbStatus.OutOfBounds)]
|
|
[InlineData(AbStatus.TooLarge)]
|
|
[InlineData(AbStatus.TooSmall)]
|
|
[InlineData(AbStatus.TypeMismatch)]
|
|
[InlineData(AbStatus.DeviceStateConflict)]
|
|
public void The_answered_class_is_an_allow_list(AbStatus status)
|
|
{
|
|
AbLegacyDriver.IsDeviceRefusal(status).ShouldBeTrue();
|
|
AbLegacyDriver.IsOpaque(status).ShouldBeFalse();
|
|
AbLegacyDriver.IsNoReply(status).ShouldBeFalse();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(AbStatus.BadReply)]
|
|
[InlineData(AbStatus.Partial)]
|
|
public void Unclassifiable_replies_are_opaque_not_answers(AbStatus status)
|
|
{
|
|
AbLegacyDriver.IsOpaque(status).ShouldBeTrue();
|
|
AbLegacyDriver.IsDeviceRefusal(status).ShouldBeFalse();
|
|
AbLegacyDriver.IsNoReply(status).ShouldBeFalse();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(AbStatus.Timeout)]
|
|
[InlineData(AbStatus.BadConnection)]
|
|
[InlineData(AbStatus.Abort)]
|
|
[InlineData(AbStatus.Pending)]
|
|
[InlineData((AbStatus)(-99))] // a code this build does not know: conservatively no reply
|
|
public void Only_no_reply_statuses_are_comms_faults(AbStatus status)
|
|
{
|
|
AbLegacyDriver.IsNoReply(status).ShouldBeTrue();
|
|
AbLegacyDriver.IsDeviceRefusal(status).ShouldBeFalse();
|
|
AbLegacyDriver.IsOpaque(status).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Every_non_Ok_status_lands_in_at_most_one_class_and_Unsupported_in_none()
|
|
{
|
|
foreach (var s in Enum.GetValues<AbStatus>().Where(s => s != AbStatus.Ok))
|
|
{
|
|
var classes = new[] { AbLegacyDriver.IsNoReply(s), AbLegacyDriver.IsDeviceRefusal(s), AbLegacyDriver.IsOpaque(s) }
|
|
.Count(c => c);
|
|
if (s == AbStatus.Unsupported) classes.ShouldBe(0, "Unsupported is neutral");
|
|
else classes.ShouldBe(1, s.ToString());
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void libplctag_remote_errors_translate_to_opaque_not_to_an_answer_or_a_fault()
|
|
{
|
|
foreach (var st in new[] { Status.ErrorRemoteErr, Status.ErrorBadStatus })
|
|
{
|
|
var s = LibplctagLegacyStatusTranslation.ToAbStatus(st);
|
|
AbLegacyDriver.IsOpaque(s).ShouldBeTrue(st.ToString());
|
|
AbLegacyDriver.IsDeviceRefusal(s).ShouldBeFalse(st.ToString());
|
|
AbLegacyDriver.IsNoReply(s).ShouldBeFalse(st.ToString());
|
|
}
|
|
// Positive controls on the other two sides.
|
|
AbLegacyDriver.IsNoReply(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorTimeout)).ShouldBeTrue();
|
|
AbLegacyDriver.IsDeviceRefusal(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorNotFound)).ShouldBeTrue();
|
|
}
|
|
|
|
// ---- behaviour: the I1 side (an opaque member never blacks out its neighbours, never trips fail-fast) ----
|
|
|
|
[Fact]
|
|
public async Task A_remote_error_member_never_blacks_out_its_coalesced_neighbours()
|
|
{
|
|
// N7:52 is past the end of the file: the block N7:48+5 and the single N7:52 both draw ERR_REMOTE_ERR.
|
|
var factory = new FakeAbLegacyTagFactory
|
|
{
|
|
Customise = p => p.ElementCount > 1 || p.TagName == "N7:52"
|
|
? new FakeAbLegacyTag(p) { Status = RemoteErr }
|
|
: new FakeAbLegacyTag(p) { Value = 48 },
|
|
};
|
|
var drv = NewDriver(factory, coalesce: true,
|
|
new AbLegacyTagDefinition("A", Host, "N7:48", AbLegacyDataType.Int),
|
|
new AbLegacyTagDefinition("B", Host, "N7:49", AbLegacyDataType.Int),
|
|
new AbLegacyTagDefinition("C", Host, "N7:52", AbLegacyDataType.Int));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
for (var poll = 0; poll < 4; poll++)
|
|
{
|
|
var r = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
|
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
|
|
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
|
|
r[2].StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good, $"poll {poll}");
|
|
}
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy); // the neighbours answered
|
|
drv.GetDeviceState(Host)!.IsThrottled(DateTime.UtcNow).ShouldBeFalse();
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Consecutive_opaque_replies_do_not_trip_fail_fast_on_an_answering_device()
|
|
{
|
|
var factory = new FakeAbLegacyTagFactory
|
|
{
|
|
Customise = p => p.TagName == "N7:9" ? new FakeAbLegacyTag(p) { Value = 9 } : new FakeAbLegacyTag(p) { Status = RemoteErr },
|
|
};
|
|
var drv = NewDriver(factory, coalesce: false,
|
|
new AbLegacyTagDefinition("R1", Host, "N7:0", AbLegacyDataType.Int),
|
|
new AbLegacyTagDefinition("R2", Host, "N7:3", AbLegacyDataType.Int),
|
|
new AbLegacyTagDefinition("R3", Host, "N7:6", AbLegacyDataType.Int),
|
|
new AbLegacyTagDefinition("Good", Host, "N7:9", AbLegacyDataType.Int));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
for (var poll = 0; poll < 3; poll++)
|
|
{
|
|
var r = await drv.ReadAsync(["R1", "R2", "R3", "Good"], CancellationToken.None);
|
|
r[3].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: never failed fast");
|
|
}
|
|
factory.Tags["N7:9"].ReadCount.ShouldBe(3);
|
|
factory.Tags["N7:0"].InitializeCount.ShouldBe(1); // opaque handles are kept, not churned
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
// ---- behaviour: the N1 side (an opaque reply never proves reachability) ----
|
|
|
|
[Fact]
|
|
public async Task Remote_error_only_reads_never_advance_LastSuccessfulRead_and_degrade()
|
|
{
|
|
// A PLC behind a bridge is powered off: every request comes back fast with ERR_REMOTE_ERR.
|
|
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
|
|
var drv = NewDriver(factory, coalesce: false,
|
|
new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int),
|
|
new AbLegacyTagDefinition("Y", Host, "N7:5", AbLegacyDataType.Int));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
(await drv.ReadAsync(["X", "Y"], CancellationToken.None)).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
|
|
var healthyAt = drv.GetHealth().LastSuccessfulRead; // positive control: Good reads DID advance it
|
|
healthyAt.ShouldNotBeNull();
|
|
foreach (var t in factory.Tags.Values) t.Status = RemoteErr;
|
|
await Task.Delay(5, TestContext.Current.CancellationToken);
|
|
|
|
for (var poll = 0; poll < 3; poll++)
|
|
(await drv.ReadAsync(["X", "Y"], CancellationToken.None)).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.BadCommunicationError);
|
|
|
|
var h = drv.GetHealth();
|
|
h.LastSuccessfulRead.ShouldBe(healthyAt); // frozen: the comms-loss watchdog can reach this device
|
|
h.State.ShouldBe(DriverState.Degraded);
|
|
drv.GetDeviceState(Host)!.IsThrottled(DateTime.UtcNow).ShouldBeFalse(); // but no fail-fast window
|
|
factory.Tags["N7:0"].ReadCount.ShouldBe(4); // every poll still asked
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
private sealed class ThrowingTag(AbLegacyTagCreateParams p, AbStatus status) : FakeAbLegacyTag(p)
|
|
{
|
|
public override Task ReadAsync(CancellationToken ct) => throw new AbTransportException(status, $"native {status}");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(AbStatus.BadReply)]
|
|
[InlineData(AbStatus.Partial)]
|
|
public async Task A_native_unclassifiable_reply_is_not_counted_as_an_answer(AbStatus status)
|
|
{
|
|
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
|
|
var drv = NewDriver(factory, coalesce: false, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
|
var healthyAt = drv.GetHealth().LastSuccessfulRead;
|
|
drv.GetDeviceState(Host)!.Runtimes.TryRemove("X", out _);
|
|
factory.Customise = p => new ThrowingTag(p, status);
|
|
await Task.Delay(5, TestContext.Current.CancellationToken);
|
|
|
|
var r = await drv.ReadAsync(["X"], CancellationToken.None);
|
|
|
|
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.MapStatus(status));
|
|
drv.GetHealth().LastSuccessfulRead.ShouldBe(healthyAt);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Positive_control_a_real_device_refusal_alone_still_proves_reachability()
|
|
{
|
|
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Status = (int)AbStatus.NotFound } };
|
|
var drv = NewDriver(factory, coalesce: false, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
var before = drv.GetHealth().LastSuccessfulRead;
|
|
await Task.Delay(5, TestContext.Current.CancellationToken);
|
|
|
|
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
|
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
}
|