diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs
index a5903127..536b4043 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs
@@ -87,4 +87,59 @@ public sealed class BrowseService : IBrowseService
new BrowseFailure(BrowseFailureKind.ServerError, ex.Message));
}
}
+
+ ///
+ public async Task SearchAsync(
+ string siteId,
+ string connectionName,
+ string query,
+ int maxDepth,
+ int maxResults,
+ CancellationToken cancellationToken = default)
+ {
+ // Same CentralUI-side role guard as BrowseChildrenAsync — sites don't
+ // enforce envelope-level roles, so the Designer check must happen here
+ // before any cross-cluster traffic.
+ var state = await _auth.GetAuthenticationStateAsync();
+ if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer))
+ {
+ return new SearchAddressSpaceResult(
+ Array.Empty(),
+ CapReached: false,
+ new BrowseFailure(BrowseFailureKind.ServerError, "Not authorized."));
+ }
+
+ try
+ {
+ return await _communication.SearchAddressSpaceAsync(
+ siteId,
+ new SearchAddressSpaceCommand(connectionName, query, maxDepth, maxResults),
+ cancellationToken);
+ }
+ catch (TimeoutException ex)
+ {
+ // Akka Ask timed out — the site (or its OPC UA session) didn't answer
+ // within CommunicationOptions.QueryTimeout. Surface as a typed Timeout
+ // failure so the picker can render an inline banner.
+ return new SearchAddressSpaceResult(
+ Array.Empty(),
+ CapReached: false,
+ new BrowseFailure(BrowseFailureKind.Timeout, ex.Message));
+ }
+ catch (OperationCanceledException)
+ {
+ // Caller-initiated cancel — propagate so Blazor can drop the response
+ // cleanly. Distinct from Timeout (which the picker renders inline).
+ throw;
+ }
+ catch (Exception ex)
+ {
+ // Any other transport / serialization failure: keep the picker alive
+ // and let the user fall back to manual browse / node-id paste.
+ return new SearchAddressSpaceResult(
+ Array.Empty(),
+ CapReached: false,
+ new BrowseFailure(BrowseFailureKind.ServerError, ex.Message));
+ }
+ }
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBrowseService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBrowseService.cs
index 8597fc49..c860a634 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBrowseService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBrowseService.cs
@@ -37,4 +37,31 @@ public interface IBrowseService
string? parentNodeId,
string? continuationToken = null,
CancellationToken cancellationToken = default);
+
+ ///
+ /// Runs a bounded recursive search of the address space on the live server
+ /// backing at ,
+ /// returning the nodes whose DisplayName or root-relative path contains
+ /// . The address-space analogue of
+ /// : it forwards a
+ /// to the owning site via
+ /// and
+ /// enforces the same Design-role trust boundary at central. The depth
+ /// and result caps are supplied by the caller (the picker's search box) and
+ /// chosen to stay well under Akka's remote frame size.
+ ///
+ /// The target site identifier.
+ /// Name of the site-local data connection to search against.
+ /// Case-insensitive substring matched against each node's DisplayName and root-relative path.
+ /// Maximum number of levels below the root to descend.
+ /// Maximum number of matches to return; when reached the walk stops early and the result's CapReached flag is set.
+ /// Cancellation token.
+ /// A task that resolves to a containing the matches or a on error.
+ Task SearchAsync(
+ string siteId,
+ string connectionName,
+ string query,
+ int maxDepth,
+ int maxResults,
+ CancellationToken cancellationToken = default);
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs
index 999585fc..58abe95c 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs
@@ -40,6 +40,39 @@ public record BrowseNodeResult(
BrowseFailure? Failure,
string? ContinuationToken = null);
+///
+/// Sent from CentralUI to a specific site to run a bounded recursive search of
+/// the address space on the live server backing the given data connection.
+///
+///
+/// The address-space analogue of : where browse
+/// walks one level at a time on user demand, search walks the tree itself
+/// (bounded by depth + result caps) and returns the nodes whose DisplayName or
+/// root-relative path contains . Keyed by
+/// for the same reason as browse — the site-side
+/// DataConnectionManagerActor indexes its children by connection name and
+/// the central DataConnections id is intentionally not exposed at the
+/// site. Routed over the same cross-cluster path as browse and resolved by the
+/// owning connection's IAddressSpaceSearchable adapter.
+///
+/// Name of the site-local data connection to search against.
+/// Case-insensitive substring matched against each node's DisplayName and root-relative path.
+/// Maximum number of levels below the root to descend. Must be non-negative.
+/// Maximum number of matches to return; when reached the walk stops early and is set.
+public record SearchAddressSpaceCommand(
+ string ConnectionName,
+ string Query,
+ int MaxDepth,
+ int MaxResults);
+
+/// The matched address-space nodes, in breadth-first discovery order (capped at ).
+/// True when a bound (result cap or the adapter's node-visit ceiling) cut the walk short, so more matches may exist than were returned.
+/// Structured failure, or null on success. Reuses the browse kinds (the search path mirrors browse exactly).
+public record SearchAddressSpaceResult(
+ IReadOnlyList Matches,
+ bool CapReached,
+ BrowseFailure? Failure);
+
public record BrowseFailure(
BrowseFailureKind Kind,
string Message);
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs
index 3b866e35..b80f627e 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs
@@ -371,6 +371,28 @@ public class CommunicationService
envelope, _options.QueryTimeout, cancellationToken);
}
+ ///
+ /// Asks a site to run a bounded recursive search of the address space on the
+ /// live server backing the given data connection. The address-space analogue
+ /// of — used by the CentralUI OPC UA tag picker's
+ /// "find a tag" box. The Ask is bounded by ,
+ /// the same latency budget as browse and the other interactive design-time
+ /// queries.
+ ///
+ /// The target site identifier.
+ /// The address-space search command (connection name + query + depth/result caps).
+ /// Cancellation token.
+ /// The search result (matches + cap-reached flag + structured failure).
+ public Task SearchAddressSpaceAsync(
+ string siteId,
+ SearchAddressSpaceCommand command,
+ CancellationToken cancellationToken = default)
+ {
+ var envelope = new SiteEnvelope(siteId, command);
+ return GetActor().Ask(
+ envelope, _options.QueryTimeout, cancellationToken);
+ }
+
// ── Test Bindings (one-shot live read of bound tags) ──
///
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs
index 4b706960..a34c99d0 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs
@@ -269,6 +269,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// an inline banner.
HandleBrowse(browse);
break;
+ case SearchAddressSpaceCommand search:
+ // Search is the address-space analogue of browse — same rule:
+ // never stash; the adapter has no session yet here, so
+ // HandleSearch short-circuits to ConnectionNotConnected.
+ HandleSearch(search);
+ break;
case ReadTagValuesCommand read:
// Same rule as browse — never stash; adapter is not yet
// connected, so HandleReadTagValues short-circuits to
@@ -353,6 +359,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
case BrowseNodeCommand browse:
HandleBrowse(browse);
break;
+ case SearchAddressSpaceCommand search:
+ HandleSearch(search);
+ break;
case ReadTagValuesCommand read:
HandleReadTagValues(read);
break;
@@ -497,6 +506,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// throw ConnectionNotConnectedException — mapped by HandleBrowse.
HandleBrowse(browse);
break;
+ case SearchAddressSpaceCommand search:
+ // Same rule as browse — never stashed; while reconnecting the
+ // adapter is not Connected so the adapter call throws
+ // ConnectionNotConnectedException, mapped by HandleSearch.
+ HandleSearch(search);
+ break;
case ReadTagValuesCommand read:
// Same rule as browse — never stashed; while reconnecting the
// adapter is not Connected so HandleReadTagValues short-circuits
@@ -1203,6 +1218,83 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
}).PipeTo(sender);
}
+ ///
+ /// Handles a forwarded by the
+ /// — the address-space analogue of
+ /// . The capability check (does this adapter support
+ /// search?) and all failure mapping live here because the adapter is held by
+ /// this actor, not the manager. The search path reuses the browse
+ /// kinds rather than inventing a parallel set.
+ ///
+ /// Failure mapping:
+ ///
+ /// - — adapter is not , or it threw (searchable adapter, but the server/protocol cannot search); message carried verbatim in the latter case.
+ /// - — adapter threw .
+ /// - — adapter threw .
+ /// - — any other exception, message carried verbatim.
+ ///
+ ///
+ /// The adapter already caps the match list at MaxResults, so unlike
+ /// there is no frame-budget clip here — the bound is
+ /// supplied by the caller (B6) and chosen to stay well under Akka's remote
+ /// frame size. The reply is sent via PipeTo(sender), the same pattern
+ /// used by .
+ ///
+ private void HandleSearch(SearchAddressSpaceCommand command)
+ {
+ var sender = Sender;
+
+ if (_adapter is not IAddressSpaceSearchable searchable)
+ {
+ _log.Debug("[{0}] Search requested but adapter does not implement IAddressSpaceSearchable", _connectionName);
+ sender.Tell(new SearchAddressSpaceResult(
+ Array.Empty(),
+ CapReached: false,
+ new BrowseFailure(
+ BrowseFailureKind.NotBrowsable,
+ $"Connection '{_connectionName}' does not support search.")));
+ return;
+ }
+
+ _log.Debug("[{0}] Searching address space for '{1}' (maxDepth={2}, maxResults={3})",
+ _connectionName, command.Query, command.MaxDepth, command.MaxResults);
+
+ searchable.SearchAddressSpaceAsync(command.Query, command.MaxDepth, command.MaxResults).ContinueWith(t =>
+ {
+ if (t.IsCompletedSuccessfully)
+ {
+ // The adapter already bounded the walk by MaxResults — pass the
+ // matches and the cap flag straight through.
+ return new SearchAddressSpaceResult(t.Result.Matches, t.Result.CapReached, Failure: null);
+ }
+
+ var baseEx = t.Exception?.GetBaseException();
+ return baseEx switch
+ {
+ ConnectionNotConnectedException notConnected => new SearchAddressSpaceResult(
+ Array.Empty(),
+ CapReached: false,
+ new BrowseFailure(BrowseFailureKind.ConnectionNotConnected, notConnected.Message)),
+ OperationCanceledException => new SearchAddressSpaceResult(
+ Array.Empty(),
+ CapReached: false,
+ new BrowseFailure(BrowseFailureKind.Timeout, "Search cancelled.")),
+ // Adapter reachable but the protocol/server cannot search. Carry
+ // the adapter's explanatory message through as NotBrowsable.
+ NotSupportedException notSupported => new SearchAddressSpaceResult(
+ Array.Empty(),
+ CapReached: false,
+ new BrowseFailure(BrowseFailureKind.NotBrowsable, notSupported.Message)),
+ _ => new SearchAddressSpaceResult(
+ Array.Empty(),
+ CapReached: false,
+ new BrowseFailure(
+ BrowseFailureKind.ServerError,
+ baseEx?.Message ?? "Unknown search error.")),
+ };
+ }).PipeTo(sender);
+ }
+
///
/// Estimated-byte ceiling for a single , kept
/// comfortably below Akka's default 128 KB remote frame size. A browse reply
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs
index a46b020e..2dec25b3 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs
@@ -50,6 +50,7 @@ public class DataConnectionManagerActor : ReceiveActor
Receive(HandleRemoveConnection);
Receive(HandleGetAllHealthReports);
Receive(HandleBrowse);
+ Receive(HandleSearch);
Receive(HandleReadTagValues);
}
@@ -189,6 +190,32 @@ public class DataConnectionManagerActor : ReceiveActor
}
}
+ ///
+ /// Routes a from the central UI's OPC
+ /// UA tag picker to the child that owns the
+ /// named connection — the address-space analogue of .
+ /// Same split: the manager owns
+ /// (only it knows the per-site connection set); the capability check and every
+ /// other failure live inside the child where the adapter is held.
+ ///
+ private void HandleSearch(SearchAddressSpaceCommand command)
+ {
+ if (_connectionActors.TryGetValue(command.ConnectionName, out var actor))
+ {
+ actor.Forward(command);
+ }
+ else
+ {
+ _log.Warning("No connection actor for {0} during search", command.ConnectionName);
+ Sender.Tell(new SearchAddressSpaceResult(
+ Array.Empty(),
+ CapReached: false,
+ new BrowseFailure(
+ BrowseFailureKind.ConnectionNotFound,
+ $"No data connection named '{command.ConnectionName}' at this site.")));
+ }
+ }
+
///
/// Routes a from the CentralUI's Test
/// Bindings dialog to the child that
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerSearchHandlerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerSearchHandlerTests.cs
new file mode 100644
index 00000000..8ac30f3a
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerSearchHandlerTests.cs
@@ -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;
+
+///
+/// Task B5 (T15): the site-side + child
+/// together resolve a
+/// against the live adapter and surface
+/// every search outcome as a typed (the search path
+/// reuses the browse failure kinds). The split mirrors browse exactly: the
+/// manager owns (only it knows
+/// the per-site connection set); the child owns the capability check
+/// ( when the adapter is not
+/// ) and the adapter-call failures
+/// ( /
+/// / ).
+///
+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();
+ _healthCollector = Substitute.For();
+ _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();
+ 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();
+ adapter.ConnectAsync(Arg.Any>(), Arg.Any())
+ .Returns(Task.CompletedTask);
+ adapter.Status.Returns(ConnectionHealth.Connected);
+ _factory.Create("OpcUa", Arg.Any>()).Returns(adapter);
+
+ var manager = Sys.ActorOf(Props.Create(() =>
+ new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
+ manager.Tell(new CreateConnectionCommand(
+ "conn-bare", "OpcUa", new Dictionary(), 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(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)adapter).ConnectAsync(Arg.Any>(), Arg.Any())
+ .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(), Arg.Any(), Arg.Any())
+ .Returns(new AddressSpaceSearchResult(matches, CapReached: true));
+
+ _factory.Create("OpcUa", Arg.Any>())
+ .Returns((IDataConnection)adapter);
+
+ var manager = Sys.ActorOf(Props.Create(() =>
+ new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
+ manager.Tell(new CreateConnectionCommand(
+ "conn-ok", "OpcUa", new Dictionary(), 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(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)adapter).ConnectAsync(Arg.Any>(), Arg.Any())
+ .Returns(Task.CompletedTask);
+ ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
+ ((IAddressSpaceSearchable)adapter)
+ .SearchAddressSpaceAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(ci =>
+ {
+ receivedQuery = ci.ArgAt(0);
+ receivedDepth = ci.ArgAt(1);
+ receivedResults = ci.ArgAt(2);
+ return new AddressSpaceSearchResult(Array.Empty(), CapReached: false);
+ });
+
+ _factory.Create("OpcUa", Arg.Any>())
+ .Returns((IDataConnection)adapter);
+
+ var manager = Sys.ActorOf(Props.Create(() =>
+ new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
+ manager.Tell(new CreateConnectionCommand(
+ "conn-args", "OpcUa", new Dictionary(), 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(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)adapter).ConnectAsync(Arg.Any>(), Arg.Any())
+ .Returns(Task.CompletedTask);
+ ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
+ ((IAddressSpaceSearchable)adapter)
+ .SearchAddressSpaceAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Task.FromException(
+ new ConnectionNotConnectedException("OPC UA session is not connected.")));
+
+ _factory.Create("OpcUa", Arg.Any>())
+ .Returns((IDataConnection)adapter);
+
+ var manager = Sys.ActorOf(Props.Create(() =>
+ new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
+ manager.Tell(new CreateConnectionCommand(
+ "conn-down", "OpcUa", new Dictionary(), 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(TimeSpan.FromSeconds(3));
+ Assert.NotNull(reply.Failure);
+ Assert.Equal(BrowseFailureKind.ConnectionNotConnected, reply.Failure!.Kind);
+ Assert.Empty(reply.Matches);
+ }
+}