Merge #625: deferred code-review findings resolved or ruled (S7.Cli verdict, browser CP release, Cluster member events, Sql Detach hoist, docs)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Cluster-002: <see cref="ClusterRoleInfo.MembersWithRole"/> must drop a member the moment it
|
||||
/// is leaving / exiting / downed, not several seconds later at <c>MemberRemoved</c>. The cluster
|
||||
/// events are fed straight into <c>HandleMemberEvent</c>, so no gossip timing is involved.
|
||||
/// </summary>
|
||||
public sealed class ClusterRoleInfoMemberEventTests
|
||||
{
|
||||
private const string SystemName = "clusterroleinfo-member-event-test";
|
||||
|
||||
private static int FreePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
// Member's factory is internal to Akka.Cluster; reflection is the only way to mint one without
|
||||
// forming a real cluster (the whole point here is to avoid gossip timing).
|
||||
private static Member NewMember(int port, MemberStatus status)
|
||||
{
|
||||
// Pick the 5-argument overload: (UniqueAddress, upNumber, status, roles, appVersion).
|
||||
var create = typeof(Member)
|
||||
.GetMethods(System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic
|
||||
| System.Reflection.BindingFlags.Public)
|
||||
.Single(m => m.Name == "Create" && m.GetParameters().Length == 5);
|
||||
var versionType = create.GetParameters()[4].ParameterType;
|
||||
var args = new object?[]
|
||||
{
|
||||
new UniqueAddress(new Address("akka.tcp", SystemName, "127.0.0.1", port), 1),
|
||||
1, status, ImmutableHashSet.Create("driver"),
|
||||
versionType.IsValueType ? Activator.CreateInstance(versionType) : null,
|
||||
};
|
||||
return (Member)create.Invoke(null, args)!;
|
||||
}
|
||||
|
||||
private static async Task WithInfoAsync(Func<ClusterRoleInfo, Task> body)
|
||||
{
|
||||
var port = FreePort();
|
||||
var options = new AkkaClusterOptions
|
||||
{
|
||||
SystemName = SystemName, Hostname = "127.0.0.1", PublicHostname = "127.0.0.1",
|
||||
Port = port, Roles = new[] { "driver" }, SeedNodes = Array.Empty<string>(),
|
||||
};
|
||||
var system = ActorSystem.Create(SystemName, Akka.Configuration.ConfigurationFactory.ParseString(
|
||||
"akka.actor.provider = cluster\nakka.remote.dot-netty.tcp.hostname = 127.0.0.1\n"
|
||||
+ $"akka.remote.dot-netty.tcp.port = {port}"));
|
||||
try
|
||||
{
|
||||
using var info = new ClusterRoleInfo(system, Options.Create(options),
|
||||
NullLogger<ClusterRoleInfo>.Instance);
|
||||
await body(info);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await system.Terminate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Each departing transition removes the member at once; the Up add is the positive control.</summary>
|
||||
[Theory]
|
||||
[InlineData("left")]
|
||||
[InlineData("exited")]
|
||||
[InlineData("downed")]
|
||||
[InlineData("removed")]
|
||||
public async Task Departing_transition_removes_the_member_immediately(string transition)
|
||||
{
|
||||
await WithInfoAsync(info =>
|
||||
{
|
||||
var up = NewMember(11111, MemberStatus.Up);
|
||||
info.HandleMemberEvent(new ClusterEvent.MemberUp(up));
|
||||
info.MembersWithRole("driver").Count.ShouldBe(1, "positive control: the Up member is listed");
|
||||
|
||||
ClusterEvent.IMemberEvent evt = transition switch
|
||||
{
|
||||
"left" => new ClusterEvent.MemberLeft(NewMember(11111, MemberStatus.Leaving)),
|
||||
"exited" => new ClusterEvent.MemberExited(NewMember(11111, MemberStatus.Exiting)),
|
||||
"downed" => new ClusterEvent.MemberDowned(NewMember(11111, MemberStatus.Down)),
|
||||
_ => new ClusterEvent.MemberRemoved(NewMember(11111, MemberStatus.Removed), MemberStatus.Exiting),
|
||||
};
|
||||
info.HandleMemberEvent(evt);
|
||||
|
||||
info.MembersWithRole("driver").ShouldBeEmpty();
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+112
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user