Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientWriteHealthTests.cs
T
Joseph Doherty cba3b2e960 fix(opcuaclient): writes feed health; a good keep-alive clears a stale Degraded
Health contract: a write response (per-node refusals included) is the server
answering, so it restores Healthy and advances LastSuccessfulRead; a write with no
reply (transport failure / timeout) writes Degraded with the stamp preserved; a
caller-cancelled write touches nothing. A good keep-alive with no outage or dead
subscription latched now clears a Degraded left by a failed read/write instead of
only moving the stamp (a subscription-only driver otherwise stayed Degraded until
its next re-initialise). All writes are generation-stamped.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:26:43 -04:00

121 lines
5.6 KiB
C#

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;
/// <summary>
/// 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
/// <see cref="DriverHealth.LastSuccessfulRead"/>; 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).
/// </summary>
[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<RawTagEntry> Tags =
[new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true)];
private static (OpcUaClientDriver Driver, Mock<ISession> Session) NewConnectedDriver(DriverHealth health)
{
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-write-health");
var session = new Mock<ISession>(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)];
/// <summary>A refused write (BadNotWritable) is a reply: Healthy again, stamp advanced.</summary>
[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<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
.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();
}
/// <summary>A write with no reply (transport failure) degrades health and preserves the stamp.</summary>
[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<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
.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");
}
/// <summary>
/// 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.)
/// </summary>
[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<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
.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);
}
/// <summary>A good keep-alive is a reply: it clears a Degraded a failed read left behind.</summary>
[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();
}
}