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; /// /// Pins BOTH sides of the reply-classification line (review 07 r0 I1 + r1 N1). /// /// No reply (Timeout / BadConnection / Abort / Pending / unknown) — comms fault: evict, Degraded, /// counts toward fail-fast. /// Answered (an allow-list: NotFound / NotAllowed / OutOfBounds / TooLarge / TooSmall / TypeMismatch / /// DeviceStateConflict) — proves reachability: per-tag Bad, Healthy, LastSuccessfulRead advances. /// Opaque (BadReply / Partial — incl. libplctag's ERR_REMOTE_ERR, 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 LastSuccessfulRead; an opaque-only device degrades so the /// comms-loss watchdog can reach a dead PLC behind a bridge. /// /// [Trait("Category", "Unit")] public sealed class AbLegacyAnsweredStatusTests { private const string Host = "ab://10.0.0.5/1,0"; /// What the libplctag runtime's GetStatus() reports for ERR_REMOTE_ERR (through the real translation). 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().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); } }