feat: OPC UA address-space search plumbing — actor + comm + BrowseService (T15)
This commit is contained in:
+212
@@ -0,0 +1,212 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Task B5 (T15): the site-side <see cref="DataConnectionManagerActor"/> + child
|
||||
/// <see cref="DataConnectionActor"/> together resolve a
|
||||
/// <see cref="SearchAddressSpaceCommand"/> against the live adapter and surface
|
||||
/// every search outcome as a typed <see cref="BrowseFailure"/> (the search path
|
||||
/// reuses the browse failure kinds). The split mirrors browse exactly: the
|
||||
/// manager owns <see cref="BrowseFailureKind.ConnectionNotFound"/> (only it knows
|
||||
/// the per-site connection set); the child owns the capability check
|
||||
/// (<see cref="BrowseFailureKind.NotBrowsable"/> when the adapter is not
|
||||
/// <see cref="IAddressSpaceSearchable"/>) and the adapter-call failures
|
||||
/// (<see cref="BrowseFailureKind.ConnectionNotConnected"/> /
|
||||
/// <see cref="BrowseFailureKind.Timeout"/> / <see cref="BrowseFailureKind.ServerError"/>).
|
||||
/// </summary>
|
||||
public class DataConnectionManagerSearchHandlerTests : TestKit
|
||||
{
|
||||
private readonly IDataConnectionFactory _factory;
|
||||
private readonly ISiteHealthCollector _healthCollector;
|
||||
private readonly DataConnectionOptions _options;
|
||||
|
||||
public DataConnectionManagerSearchHandlerTests()
|
||||
: base(@"akka.loglevel = WARNING")
|
||||
{
|
||||
_factory = Substitute.For<IDataConnectionFactory>();
|
||||
_healthCollector = Substitute.For<ISiteHealthCollector>();
|
||||
_options = new DataConnectionOptions
|
||||
{
|
||||
ReconnectInterval = TimeSpan.FromSeconds(30),
|
||||
TagResolutionRetryInterval = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_connection_name_returns_ConnectionNotFound()
|
||||
{
|
||||
var manager = Sys.ActorOf(Props.Create(() =>
|
||||
new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
|
||||
|
||||
// No CreateConnectionCommand sent — the manager has zero children, so a
|
||||
// search against any name must be rejected with ConnectionNotFound (the
|
||||
// manager is the only actor with site-level visibility).
|
||||
manager.Tell(new SearchAddressSpaceCommand("unknown-connection", "Tag", 5, 100));
|
||||
|
||||
var reply = ExpectMsg<SearchAddressSpaceResult>();
|
||||
Assert.NotNull(reply.Failure);
|
||||
Assert.Equal(BrowseFailureKind.ConnectionNotFound, reply.Failure!.Kind);
|
||||
Assert.Empty(reply.Matches);
|
||||
Assert.False(reply.CapReached);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Non_searchable_adapter_returns_NotBrowsable()
|
||||
{
|
||||
// Bare IDataConnection — no IAddressSpaceSearchable. The child actor's
|
||||
// capability check must surface this as NotBrowsable.
|
||||
var adapter = Substitute.For<IDataConnection>();
|
||||
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
adapter.Status.Returns(ConnectionHealth.Connected);
|
||||
_factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>()).Returns(adapter);
|
||||
|
||||
var manager = Sys.ActorOf(Props.Create(() =>
|
||||
new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
|
||||
manager.Tell(new CreateConnectionCommand(
|
||||
"conn-bare", "OpcUa", new Dictionary<string, string>(), null, 3));
|
||||
|
||||
AwaitCondition(
|
||||
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
|
||||
TimeSpan.FromSeconds(2));
|
||||
|
||||
manager.Tell(new SearchAddressSpaceCommand("conn-bare", "Tag", 5, 100));
|
||||
|
||||
var reply = ExpectMsg<SearchAddressSpaceResult>(TimeSpan.FromSeconds(3));
|
||||
Assert.NotNull(reply.Failure);
|
||||
Assert.Equal(BrowseFailureKind.NotBrowsable, reply.Failure!.Kind);
|
||||
Assert.Empty(reply.Matches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Success_path_returns_mapped_matches()
|
||||
{
|
||||
// Adapter implementing both IDataConnection (so DataConnectionActor can run
|
||||
// its lifecycle) AND IAddressSpaceSearchable (so the search handler takes
|
||||
// the success path).
|
||||
var adapter = Substitute.For<IDataConnection, IAddressSpaceSearchable>();
|
||||
((IDataConnection)adapter).ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
|
||||
|
||||
var matches = new[]
|
||||
{
|
||||
new AddressSpaceMatch(
|
||||
new BrowseNode("ns=2;s=A", "TagA", BrowseNodeClass.Variable, HasChildren: false),
|
||||
"/Folder1/TagA"),
|
||||
new AddressSpaceMatch(
|
||||
new BrowseNode("ns=2;s=B", "TagB", BrowseNodeClass.Variable, HasChildren: false),
|
||||
"/Folder2/TagB"),
|
||||
};
|
||||
((IAddressSpaceSearchable)adapter)
|
||||
.SearchAddressSpaceAsync("Tag", Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new AddressSpaceSearchResult(matches, CapReached: true));
|
||||
|
||||
_factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>())
|
||||
.Returns((IDataConnection)adapter);
|
||||
|
||||
var manager = Sys.ActorOf(Props.Create(() =>
|
||||
new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
|
||||
manager.Tell(new CreateConnectionCommand(
|
||||
"conn-ok", "OpcUa", new Dictionary<string, string>(), null, 3));
|
||||
|
||||
AwaitCondition(
|
||||
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
|
||||
TimeSpan.FromSeconds(2));
|
||||
|
||||
manager.Tell(new SearchAddressSpaceCommand("conn-ok", "Tag", 5, 100));
|
||||
|
||||
var reply = ExpectMsg<SearchAddressSpaceResult>(TimeSpan.FromSeconds(3));
|
||||
Assert.Null(reply.Failure);
|
||||
Assert.Equal(2, reply.Matches.Count);
|
||||
Assert.Equal("ns=2;s=A", reply.Matches[0].Node.NodeId);
|
||||
Assert.Equal("/Folder1/TagA", reply.Matches[0].Path);
|
||||
Assert.Equal("ns=2;s=B", reply.Matches[1].Node.NodeId);
|
||||
Assert.True(reply.CapReached);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_depth_and_results_thread_through_to_the_adapter()
|
||||
{
|
||||
// The command's Query/MaxDepth/MaxResults must reach the adapter verbatim.
|
||||
string? receivedQuery = "NOT-CALLED";
|
||||
int receivedDepth = -1;
|
||||
int receivedResults = -1;
|
||||
|
||||
var adapter = Substitute.For<IDataConnection, IAddressSpaceSearchable>();
|
||||
((IDataConnection)adapter).ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
|
||||
((IAddressSpaceSearchable)adapter)
|
||||
.SearchAddressSpaceAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
receivedQuery = ci.ArgAt<string>(0);
|
||||
receivedDepth = ci.ArgAt<int>(1);
|
||||
receivedResults = ci.ArgAt<int>(2);
|
||||
return new AddressSpaceSearchResult(Array.Empty<AddressSpaceMatch>(), CapReached: false);
|
||||
});
|
||||
|
||||
_factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>())
|
||||
.Returns((IDataConnection)adapter);
|
||||
|
||||
var manager = Sys.ActorOf(Props.Create(() =>
|
||||
new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
|
||||
manager.Tell(new CreateConnectionCommand(
|
||||
"conn-args", "OpcUa", new Dictionary<string, string>(), null, 3));
|
||||
|
||||
AwaitCondition(
|
||||
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
|
||||
TimeSpan.FromSeconds(2));
|
||||
|
||||
manager.Tell(new SearchAddressSpaceCommand("conn-args", "Boiler", 7, 250));
|
||||
|
||||
var reply = ExpectMsg<SearchAddressSpaceResult>(TimeSpan.FromSeconds(3));
|
||||
Assert.Null(reply.Failure);
|
||||
Assert.Equal("Boiler", receivedQuery);
|
||||
Assert.Equal(7, receivedDepth);
|
||||
Assert.Equal(250, receivedResults);
|
||||
Assert.Empty(reply.Matches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectionNotConnectedException_maps_to_ConnectionNotConnected()
|
||||
{
|
||||
var adapter = Substitute.For<IDataConnection, IAddressSpaceSearchable>();
|
||||
((IDataConnection)adapter).ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
|
||||
((IAddressSpaceSearchable)adapter)
|
||||
.SearchAddressSpaceAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromException<AddressSpaceSearchResult>(
|
||||
new ConnectionNotConnectedException("OPC UA session is not connected.")));
|
||||
|
||||
_factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>())
|
||||
.Returns((IDataConnection)adapter);
|
||||
|
||||
var manager = Sys.ActorOf(Props.Create(() =>
|
||||
new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
|
||||
manager.Tell(new CreateConnectionCommand(
|
||||
"conn-down", "OpcUa", new Dictionary<string, string>(), null, 3));
|
||||
|
||||
AwaitCondition(
|
||||
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
|
||||
TimeSpan.FromSeconds(2));
|
||||
|
||||
manager.Tell(new SearchAddressSpaceCommand("conn-down", "Tag", 5, 100));
|
||||
|
||||
var reply = ExpectMsg<SearchAddressSpaceResult>(TimeSpan.FromSeconds(3));
|
||||
Assert.NotNull(reply.Failure);
|
||||
Assert.Equal(BrowseFailureKind.ConnectionNotConnected, reply.Failure!.Kind);
|
||||
Assert.Empty(reply.Matches);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user