cba3b2e960
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
121 lines
5.6 KiB
C#
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();
|
|
}
|
|
}
|