4b6ff49822
Expanding a Galaxy object in the tag picker hung on "loading…": the browse reply inlined every child's full attribute set (~152 KB), exceeding Akka's 128 KB remote frame, and remoting silently discarded the oversized reply. Browse path (DataConnectionLayer): - RealMxGatewayClient: navigation now uses BrowseChildren(include_attributes= false) — child objects only — and an object's own attributes load lazily via DiscoverHierarchy(root, max_depth=0) when it's expanded. Payload drops from ~152 KB/level to a few KB. Seam contract unchanged. - DataConnectionActor.CapBrowseChildren: protocol-agnostic byte-budget cap (~100 KB) on every BrowseNodeResult before it crosses the site→central frame, OR-ing the adapter's own Truncated flag. Byte budget, not a count — the only bound that holds regardless of NodeId/attribute-name length. - RealOpcUaClient: requestedMaxReferencesPerNode 1000 → 500 to narrow the window before the byte budget applies. - Graceful gRPC Unimplemented handling → NotSupportedException → BrowseFailureKind.NotBrowsable with an actionable message (older gateway builds lacking BrowseChildren). Picker UI (CentralUI): - NodeBrowserDialog: modal-lg → modal-xl; new scoped .razor.css caps the tree at 55vh with its own scrollbar so manual entry + Select/Cancel stay visible. - Protocol-agnostic failure messages (was hardcoded "OPC UA …"); renamed the leftover opcua-browser-tree class to node-browser-tree. Tests: new frame-budget cap test + NotSupported=>NotBrowsable mapping test; DCL suite 88/88. Doc: Component-DataConnectionLayer.md records the lazy attribute-light browse and the frame-size guard.
257 lines
12 KiB
C#
257 lines
12 KiB
C#
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 10 (opcua-tag-browser): the site-side
|
|
/// <see cref="DataConnectionManagerActor"/> + child
|
|
/// <see cref="DataConnectionActor"/> together resolve
|
|
/// <see cref="BrowseNodeCommand"/> against the live adapter and surface
|
|
/// every browse outcome as a typed <see cref="BrowseFailure"/>. The split is:
|
|
/// the manager owns <see cref="BrowseFailureKind.ConnectionNotFound"/> (only it
|
|
/// knows the per-site connection set); everything else lives in the child where
|
|
/// the adapter is held — <see cref="BrowseFailureKind.NotBrowsable"/> from the
|
|
/// capability check, <see cref="BrowseFailureKind.ConnectionNotConnected"/> /
|
|
/// <see cref="BrowseFailureKind.Timeout"/> / <see cref="BrowseFailureKind.ServerError"/>
|
|
/// from the adapter call. These tests guard that split.
|
|
/// </summary>
|
|
public class DataConnectionManagerBrowseHandlerTests : TestKit
|
|
{
|
|
private readonly IDataConnectionFactory _factory;
|
|
private readonly ISiteHealthCollector _healthCollector;
|
|
private readonly DataConnectionOptions _options;
|
|
|
|
public DataConnectionManagerBrowseHandlerTests()
|
|
: 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
|
|
// browse against any name must be rejected with ConnectionNotFound
|
|
// (the manager is the only actor with site-level visibility).
|
|
manager.Tell(new BrowseNodeCommand("unknown-connection", ParentNodeId: null));
|
|
|
|
var reply = ExpectMsg<BrowseNodeResult>();
|
|
Assert.NotNull(reply.Failure);
|
|
Assert.Equal(BrowseFailureKind.ConnectionNotFound, reply.Failure!.Kind);
|
|
Assert.Empty(reply.Children);
|
|
}
|
|
|
|
[Fact]
|
|
public void Non_browsable_adapter_returns_NotBrowsable()
|
|
{
|
|
// Bare IDataConnection — no IBrowsableDataConnection. 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));
|
|
|
|
// Give the manager a moment to spawn the child actor. We do not need to
|
|
// wait for Connected — the browse handler runs in all states.
|
|
AwaitCondition(
|
|
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
|
|
TimeSpan.FromSeconds(2));
|
|
|
|
manager.Tell(new BrowseNodeCommand("conn-bare", ParentNodeId: null));
|
|
|
|
var reply = ExpectMsg<BrowseNodeResult>(TimeSpan.FromSeconds(3));
|
|
Assert.NotNull(reply.Failure);
|
|
Assert.Equal(BrowseFailureKind.NotBrowsable, reply.Failure!.Kind);
|
|
Assert.Empty(reply.Children);
|
|
}
|
|
|
|
[Fact]
|
|
public void Success_path_returns_mapped_children()
|
|
{
|
|
// Adapter implementing both IDataConnection (so DataConnectionActor can
|
|
// run its lifecycle) AND IBrowsableDataConnection (so the browse handler
|
|
// takes the success path).
|
|
var adapter = Substitute.For<IDataConnection, IBrowsableDataConnection>();
|
|
((IDataConnection)adapter).ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.CompletedTask);
|
|
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
|
|
|
|
var children = new[]
|
|
{
|
|
new BrowseNode("ns=2;s=A", "A", BrowseNodeClass.Variable, HasChildren: false),
|
|
new BrowseNode("ns=2;s=B", "B", BrowseNodeClass.Object, HasChildren: true),
|
|
};
|
|
((IBrowsableDataConnection)adapter)
|
|
.BrowseChildrenAsync(null, Arg.Any<CancellationToken>())
|
|
.Returns(new BrowseChildrenResult(children, Truncated: 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-ok", "OpcUa", new Dictionary<string, string>(), null, 3));
|
|
|
|
AwaitCondition(
|
|
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
|
|
TimeSpan.FromSeconds(2));
|
|
|
|
manager.Tell(new BrowseNodeCommand("conn-ok", ParentNodeId: null));
|
|
|
|
var reply = ExpectMsg<BrowseNodeResult>(TimeSpan.FromSeconds(3));
|
|
Assert.Null(reply.Failure);
|
|
Assert.Equal(2, reply.Children.Count);
|
|
Assert.Equal("ns=2;s=A", reply.Children[0].NodeId);
|
|
Assert.Equal("ns=2;s=B", reply.Children[1].NodeId);
|
|
Assert.False(reply.Truncated);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConnectionNotConnectedException_maps_to_ConnectionNotConnected()
|
|
{
|
|
var adapter = Substitute.For<IDataConnection, IBrowsableDataConnection>();
|
|
((IDataConnection)adapter).ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.CompletedTask);
|
|
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
|
|
|
|
((IBrowsableDataConnection)adapter)
|
|
.BrowseChildrenAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromException<BrowseChildrenResult>(
|
|
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 BrowseNodeCommand("conn-down", ParentNodeId: null));
|
|
|
|
var reply = ExpectMsg<BrowseNodeResult>(TimeSpan.FromSeconds(3));
|
|
Assert.NotNull(reply.Failure);
|
|
Assert.Equal(BrowseFailureKind.ConnectionNotConnected, reply.Failure!.Kind);
|
|
Assert.Empty(reply.Children);
|
|
}
|
|
|
|
[Fact]
|
|
public void NotSupportedException_maps_to_NotBrowsable_carrying_message()
|
|
{
|
|
// A browsable adapter that is connected but whose server/protocol cannot
|
|
// browse (e.g. an MxGateway build predating the BrowseChildren RPC, which
|
|
// answers gRPC Unimplemented) throws NotSupportedException. The actor must
|
|
// surface this as NotBrowsable with the adapter's actionable message
|
|
// carried verbatim — distinct from the capability-check NotBrowsable
|
|
// (non-browsable adapter), which has no message.
|
|
const string reason =
|
|
"The connected MxGateway build does not support hierarchy browsing. "
|
|
+ "Update the gateway to a build that implements BrowseChildren, "
|
|
+ "or enter the tag reference manually.";
|
|
|
|
var adapter = Substitute.For<IDataConnection, IBrowsableDataConnection>();
|
|
((IDataConnection)adapter).ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.CompletedTask);
|
|
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
|
|
|
|
((IBrowsableDataConnection)adapter)
|
|
.BrowseChildrenAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromException<BrowseChildrenResult>(new NotSupportedException(reason)));
|
|
|
|
_factory.Create("MxGateway", 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-old-gw", "MxGateway", new Dictionary<string, string>(), null, 3));
|
|
|
|
AwaitCondition(
|
|
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
|
|
TimeSpan.FromSeconds(2));
|
|
|
|
manager.Tell(new BrowseNodeCommand("conn-old-gw", ParentNodeId: null));
|
|
|
|
var reply = ExpectMsg<BrowseNodeResult>(TimeSpan.FromSeconds(3));
|
|
Assert.NotNull(reply.Failure);
|
|
Assert.Equal(BrowseFailureKind.NotBrowsable, reply.Failure!.Kind);
|
|
Assert.Equal(reason, reply.Failure.Message);
|
|
Assert.Empty(reply.Children);
|
|
}
|
|
|
|
[Fact]
|
|
public void Oversized_child_list_is_capped_to_frame_budget_and_marked_truncated()
|
|
{
|
|
// A level large enough to exceed Akka's 128 KB remote frame must be clipped
|
|
// by the actor BEFORE it crosses the site→central boundary — otherwise the
|
|
// reply is silently discarded and the picker hangs. This guards every
|
|
// protocol's reply, regardless of how the adapter paginates upstream. The
|
|
// adapter here reports Truncated=false; the actor must still truncate purely
|
|
// on serialized size.
|
|
const int byteBudget = 100 * 1024; // mirrors DataConnectionActor.BrowseResultByteBudget
|
|
var bigList = Enumerable.Range(0, 3000)
|
|
.Select(i => new BrowseNode($"ns=2;s=Item{i:D5}", $"Item{i:D5}", BrowseNodeClass.Variable, HasChildren: false))
|
|
.ToArray();
|
|
|
|
var adapter = Substitute.For<IDataConnection, IBrowsableDataConnection>();
|
|
((IDataConnection)adapter).ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.CompletedTask);
|
|
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
|
|
((IBrowsableDataConnection)adapter)
|
|
.BrowseChildrenAsync(null, Arg.Any<CancellationToken>())
|
|
.Returns(new BrowseChildrenResult(bigList, Truncated: 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-big", "OpcUa", new Dictionary<string, string>(), null, 3));
|
|
|
|
AwaitCondition(
|
|
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
|
|
TimeSpan.FromSeconds(2));
|
|
|
|
manager.Tell(new BrowseNodeCommand("conn-big", ParentNodeId: null));
|
|
|
|
var reply = ExpectMsg<BrowseNodeResult>(TimeSpan.FromSeconds(3));
|
|
Assert.Null(reply.Failure);
|
|
Assert.True(reply.Truncated, "an oversized level must be reported as truncated");
|
|
Assert.True(reply.Children.Count > 0, "the cap must still return a usable prefix");
|
|
Assert.True(reply.Children.Count < bigList.Length, "the level must actually be clipped");
|
|
|
|
// The kept prefix's estimated serialized size must respect the budget.
|
|
var keptBytes = reply.Children.Sum(n => 64 + n.NodeId.Length + n.DisplayName.Length);
|
|
Assert.True(keptBytes <= byteBudget, $"kept estimate {keptBytes} exceeds budget {byteBudget}");
|
|
}
|
|
}
|