using System.Text.Json; using Moq; using Opc.Ua; using Opc.Ua.Client; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// /// The health contract for the write path and the steady keep-alive: a write RESPONSE — whatever per-node /// statuses it carries, a refusal included — is the server answering, so it restores Healthy and advances /// ; a write that got NO response degrades health with the stamp /// preserved; a caller-cancelled write is not a comms fault. A good keep-alive clears a Degraded left by a failed /// read/write (it used to only move the stamp, leaving a subscription-only driver Degraded forever). /// [Trait("Category", "Unit")] public sealed class OpcUaClientWriteHealthTests { private const string SpeedRef = "Plant/dev/Speed"; private static readonly DateTime Stale = new(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc); private static readonly IReadOnlyList Tags = [new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true)]; private static (OpcUaClientDriver Driver, Mock Session) NewConnectedDriver(DriverHealth health) { var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-write-health"); var session = new Mock(MockBehavior.Loose); session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context)); session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); drv.SetSessionForTest(session.Object); drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags)); drv.SetHealthForTest(health); return (drv, session); } private static ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest[] Write() => [new(SpeedRef, 7)]; /// A refused write (BadNotWritable) is a reply: Healthy again, stamp advanced. [Fact] public async Task A_refused_write_is_a_reply_and_restores_healthy() { var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Degraded, Stale, "read failed")); using var _ = drv; session.Setup(s => s.WriteAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new WriteResponse { ResponseHeader = new ResponseHeader(), Results = [StatusCodes.BadNotWritable], DiagnosticInfos = [], }); var results = await drv.WriteAsync(Write(), TestContext.Current.CancellationToken); results.Single().StatusCode.ShouldBe(StatusCodes.BadNotWritable, "the upstream refusal still passes through verbatim"); var h = drv.GetHealth(); h.State.ShouldBe(DriverState.Healthy); h.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(Stale); h.LastError.ShouldBeNull(); } /// A write with no reply (transport failure) degrades health and preserves the stamp. [Fact] public async Task A_write_with_no_reply_degrades_and_preserves_the_stamp() { var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Healthy, Stale, null)); using var _ = drv; session.Setup(s => s.WriteAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new ServiceResultException(StatusCodes.BadConnectionClosed)); var results = await drv.WriteAsync(Write(), TestContext.Current.CancellationToken); results.Single().StatusCode.ShouldBe(0x80050000u); // BadCommunicationError var h = drv.GetHealth(); h.State.ShouldBe(DriverState.Degraded); h.LastSuccessfulRead.ShouldBe(Stale, "the comms-loss watchdog measures silence from this stamp"); } /// /// A write the CALLER cancelled says nothing about the upstream: health is left alone. (The positive control is /// the test above — the same throw-path does write health when the failure is the transport's.) /// [Fact] public async Task A_caller_cancelled_write_leaves_health_alone() { var healthy = new DriverHealth(DriverState.Healthy, Stale, null); var (drv, session) = NewConnectedDriver(healthy); using var _ = drv; using var cts = new CancellationTokenSource(); session.Setup(s => s.WriteAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(async (RequestHeader _, WriteValueCollection _, CancellationToken ct) => { cts.Cancel(); await Task.Delay(Timeout.Infinite, ct); return new WriteResponse(); }); var results = await drv.WriteAsync(Write(), cts.Token); results.Single().StatusCode.ShouldBe(0x800A0000u); // BadTimeout — outcome unknown drv.GetHealth().ShouldBe(healthy); } /// A good keep-alive is a reply: it clears a Degraded a failed read left behind. [Fact] public void A_good_keep_alive_clears_a_degraded_left_by_a_failed_read() { var (drv, _) = NewConnectedDriver(new DriverHealth(DriverState.Degraded, Stale, "read: BadTimeout")); using var __ = drv; drv.RaiseKeepAliveForTest(StatusCodes.Good); var h = drv.GetHealth(); h.State.ShouldBe(DriverState.Healthy); h.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(Stale); h.LastError.ShouldBeNull(); } }