fix(review): resolve deferred code-review findings; S7.Cli verdict, browser CP release, MemberLeft/Exited/Downed, Sql Detach hoist (#625)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 06:59:53 -04:00
parent 3baa332d6b
commit 3fb6549209
22 changed files with 423 additions and 88 deletions
@@ -0,0 +1,66 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests;
/// <summary>
/// Covers <see cref="ProbeCommand.ComputeVerdict"/> — the headline-string helper that
/// combines the driver-side <see cref="DriverState"/> with the probe snapshot's OPC UA
/// <see cref="DataValueSnapshot.StatusCode"/> so the operator never sees the previous
/// contradictory pair (`Health: Healthy` above a `Status: Bad...` snapshot line — see
/// Driver.S7.Cli-012).
/// </summary>
[Trait("Category", "Unit")]
public sealed class ProbeCommandTests
{
/// <summary>Verifies that ComputeVerdict returns OK when driver state is Healthy and status is Good.</summary>
[Fact]
public void ComputeVerdict_returns_OK_when_state_is_Healthy_and_status_is_Good()
{
var verdict = ProbeCommand.ComputeVerdict(DriverState.Healthy, statusCode: 0x00000000u);
verdict.ShouldContain("OK");
}
/// <summary>Verifies that ComputeVerdict returns FAIL when driver state is not Healthy.</summary>
/// <param name="state">The driver state to test.</param>
[Theory]
[InlineData(DriverState.Faulted)]
[InlineData(DriverState.Reconnecting)]
[InlineData(DriverState.Unknown)]
public void ComputeVerdict_returns_FAIL_when_driver_state_is_not_Healthy(DriverState state)
{
var verdict = ProbeCommand.ComputeVerdict(state, statusCode: 0x00000000u);
verdict.ShouldContain("FAIL");
}
/// <summary>Verifies that ComputeVerdict returns FAIL when snapshot status is Bad even if driver is Healthy.</summary>
/// <param name="statusCode">The OPC UA status code to test.</param>
[Theory]
[InlineData(0x80050000u)] // BadCommunicationError
[InlineData(0x800A0000u)] // BadTimeout
[InlineData(0x80740000u)] // BadTypeMismatch
[InlineData(0x80000000u)] // generic Bad
public void ComputeVerdict_returns_FAIL_when_snapshot_status_is_Bad_even_if_driver_state_Healthy(uint statusCode)
{
// Driver.S7.Cli-012: a successful InitializeAsync sets DriverState.Healthy, but
// the probe read may still fail (snapshot.StatusCode != Good). Previously the
// headline reported Healthy while the snapshot line below showed Bad. The verdict
// must reflect the actual probe-read outcome.
var verdict = ProbeCommand.ComputeVerdict(DriverState.Healthy, statusCode);
verdict.ShouldContain("FAIL");
}
/// <summary>Verifies that ComputeVerdict returns DEGRADED for uncertain status with healthy driver.</summary>
[Fact]
public void ComputeVerdict_returns_DEGRADED_for_uncertain_status_with_healthy_driver()
{
var verdict = ProbeCommand.ComputeVerdict(DriverState.Healthy, statusCode: 0x40000000u);
verdict.ShouldContain("DEGRADED");
}
}
@@ -0,0 +1,112 @@
using Moq;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests;
/// <summary>
/// Driver.OpcUaClient.Browser-002: when the caller cancels during the <c>BrowseNextAsync</c>
/// pagination loop, the server-side continuation point must be released
/// (<c>releaseContinuationPoints: true</c>) before the cancellation propagates. Mirrors the
/// runtime driver's <c>OpcUaClientContinuationPointReleaseTests</c>; no live server needed.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientBrowseSessionPaginationTests
{
private static readonly byte[] FakeContinuationPoint = [0x01, 0x02, 0x03];
private static Mock<ISession> BuildSession(bool cancelNext)
{
var mock = new Mock<ISession>(MockBehavior.Loose);
mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext());
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
var firstRef = new ReferenceDescription
{
NodeId = new ExpandedNodeId(new NodeId(1000u)),
BrowseName = new QualifiedName("TestObject"),
DisplayName = new LocalizedText("TestObject"),
NodeClass = NodeClass.Object,
};
mock.Setup(s => s.BrowseAsync(
It.IsAny<RequestHeader>(), It.IsAny<ViewDescription>(), It.IsAny<uint>(),
It.IsAny<BrowseDescriptionCollection>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new BrowseResponse
{
ResponseHeader = new ResponseHeader(),
Results = new BrowseResultCollection
{
new BrowseResult
{
ContinuationPoint = FakeContinuationPoint,
References = new ReferenceDescriptionCollection { firstRef },
},
},
DiagnosticInfos = new DiagnosticInfoCollection(),
});
var emptyPage = new BrowseNextResponse
{
ResponseHeader = new ResponseHeader(),
Results = new BrowseResultCollection
{
new BrowseResult
{
ContinuationPoint = Array.Empty<byte>(),
References = new ReferenceDescriptionCollection(),
},
},
DiagnosticInfos = new DiagnosticInfoCollection(),
};
var pageSetup = mock.Setup(s => s.BrowseNextAsync(
It.IsAny<RequestHeader>(), It.Is<bool>(r => !r),
It.IsAny<ByteStringCollection>(), It.IsAny<CancellationToken>()));
if (cancelNext) pageSetup.ThrowsAsync(new OperationCanceledException("cancelled mid-page"));
else pageSetup.ReturnsAsync(emptyPage);
mock.Setup(s => s.BrowseNextAsync(
It.IsAny<RequestHeader>(), It.Is<bool>(r => r),
It.IsAny<ByteStringCollection>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(emptyPage);
return mock;
}
private static OpcUaClientBrowseSession NewSession(Mock<ISession> mock)
=> new(mock.Object, NamespaceMap.FromTable(new NamespaceTable()), ObjectIds.ObjectsFolder);
/// <summary>Cancellation mid-pagination releases the continuation point exactly once.</summary>
[Fact]
public async Task RootAsync_releases_continuation_point_when_cancelled_mid_pagination()
{
var mock = BuildSession(cancelNext: true);
await using var session = NewSession(mock);
await Should.ThrowAsync<OperationCanceledException>(async () =>
await session.RootAsync(TestContext.Current.CancellationToken));
mock.Verify(s => s.BrowseNextAsync(
It.IsAny<RequestHeader>(), It.Is<bool>(r => r),
It.IsAny<ByteStringCollection>(), It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>Positive control: an uncancelled pagination never issues a release call.</summary>
[Fact]
public async Task RootAsync_does_not_release_when_pagination_completes()
{
var mock = BuildSession(cancelNext: false);
await using var session = NewSession(mock);
var nodes = await session.RootAsync(TestContext.Current.CancellationToken);
nodes.Count.ShouldBe(1);
mock.Verify(s => s.BrowseNextAsync(
It.IsAny<RequestHeader>(), It.Is<bool>(r => !r),
It.IsAny<ByteStringCollection>(), It.IsAny<CancellationToken>()), Times.Once);
mock.Verify(s => s.BrowseNextAsync(
It.IsAny<RequestHeader>(), It.Is<bool>(r => r),
It.IsAny<ByteStringCollection>(), It.IsAny<CancellationToken>()), Times.Never);
}
}