diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GatewayGalaxyPlatformSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GatewayGalaxyPlatformSource.cs
index dfed16597..875bbddc9 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GatewayGalaxyPlatformSource.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GatewayGalaxyPlatformSource.cs
@@ -5,11 +5,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
///
/// Default : a platform-only DiscoverHierarchy sweep
-/// over the gateway's — the request filters on
-/// template_chain_contains ($WinPlatform, $AppEngine) and asks for no
-/// attributes, so it is a few rows per galaxy rather than the full object + attribute dump the
-/// address-space discovery pulls. Paged at the configured Repository.DiscoverPageSize.
+/// over the gateway's — one sweep per platform template
+/// ($WinPlatform, then $AppEngine), each filtered on that single
+/// template_chain_contains token with no attributes, so it is a few rows per galaxy rather
+/// than the full object + attribute dump the address-space discovery pulls. Paged at the
+/// configured Repository.DiscoverPageSize.
///
+///
+/// ⚠️ One template per request (#864). The gateway's shared
+/// GalaxyHierarchyProjector.MatchesFilters ANDs the template_chain_contains entries —
+/// an object must match every token — so a single request carrying both templates matches no
+/// platform (a $WinPlatform's chain has no $AppEngine, and vice versa) and the probe
+/// watcher seeded 0 platforms in production. The sweeps are unioned and de-duplicated by tag name.
+///
public sealed class GatewayGalaxyPlatformSource : IGalaxyPlatformSource
{
/// Template-chain tokens that identify a platform / engine object.
@@ -32,15 +40,16 @@ public sealed class GatewayGalaxyPlatformSource : IGalaxyPlatformSource
=> QueryAsync(_client.DiscoverHierarchyRawAsync, _pageSize, cancellationToken);
///
- /// The platform-only sweep behind a delegate so it is unit-testable without a channel. Every
- /// request carries the template filter and include_attributes = false; the reply is
+ /// The platform-only sweeps behind a delegate so it is unit-testable without a channel. One
+ /// paged sweep per entry of ; every request carries exactly that
+ /// one template token and include_attributes = false; the reply is
/// re-filtered on the returned template chain (a gateway that ignores the filter still
/// yields only platforms) and reduced to non-empty tag names.
///
/// The raw DiscoverHierarchy RPC.
/// Objects per page.
/// Cancellation token for the walk.
- /// Distinct platform tag names in gateway order.
+ /// Distinct platform tag names: $WinPlatform sweep first, then $AppEngine, each in gateway order.
internal static async Task> QueryAsync(
Func> discover,
int pageSize,
@@ -51,33 +60,37 @@ public sealed class GatewayGalaxyPlatformSource : IGalaxyPlatformSource
var names = new List();
var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
- var seenPageTokens = new HashSet(StringComparer.Ordinal);
- var pageToken = string.Empty;
- do
+ foreach (var template in PlatformTemplates)
{
- var request = new DiscoverHierarchyRequest
+ var seenPageTokens = new HashSet(StringComparer.Ordinal);
+ var pageToken = string.Empty;
+ do
{
- PageSize = effective,
- PageToken = pageToken,
- IncludeAttributes = false,
- };
- request.TemplateChainContains.Add(PlatformTemplates);
+ var request = new DiscoverHierarchyRequest
+ {
+ PageSize = effective,
+ PageToken = pageToken,
+ IncludeAttributes = false,
+ };
+ // Exactly ONE token per request: the gateway ANDs template filters (#864).
+ request.TemplateChainContains.Add(template);
- var reply = await discover(request, cancellationToken).ConfigureAwait(false);
- foreach (var obj in reply.Objects)
- {
- if (string.IsNullOrEmpty(obj.TagName) || !IsPlatform(obj)) continue;
- if (seen.Add(obj.TagName)) names.Add(obj.TagName);
- }
+ var reply = await discover(request, cancellationToken).ConfigureAwait(false);
+ foreach (var obj in reply.Objects)
+ {
+ if (string.IsNullOrEmpty(obj.TagName) || !IsPlatform(obj)) continue;
+ if (seen.Add(obj.TagName)) names.Add(obj.TagName);
+ }
- pageToken = reply.NextPageToken ?? string.Empty;
- if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
- {
- throw new InvalidOperationException(
- $"Galaxy DiscoverHierarchy (platform query) returned a repeated page token '{pageToken}' — refusing to loop.");
+ pageToken = reply.NextPageToken ?? string.Empty;
+ if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
+ {
+ throw new InvalidOperationException(
+ $"Galaxy DiscoverHierarchy (platform query, {template}) returned a repeated page token '{pageToken}' — refusing to loop.");
+ }
}
+ while (!string.IsNullOrWhiteSpace(pageToken));
}
- while (!string.IsNullOrWhiteSpace(pageToken));
return names;
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Browse/GatewayGalaxyPlatformSourceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Browse/GatewayGalaxyPlatformSourceTests.cs
index a385ecfb8..130d98de3 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Browse/GatewayGalaxyPlatformSourceTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Browse/GatewayGalaxyPlatformSourceTests.cs
@@ -15,33 +15,99 @@ public sealed class GatewayGalaxyPlatformSourceTests
return o;
}
- /// Every request carries the platform template filter, no attributes, and the page size; replies are re-filtered.
- [Fact]
- public async Task Requests_filter_on_platform_templates_without_attributes_and_replies_are_refiltered()
+ ///
+ /// A fake gateway that applies the request filters with the SAME semantics as the shared
+ /// GalaxyHierarchyProjector.MatchesFilters: every template_chain_contains token must
+ /// match (AND, case-insensitive substring over the chain), category_ids is an OR set. Pages
+ /// the filtered result at the request's page size.
+ ///
+ private sealed class FilteringGateway(params GalaxyObject[] galaxy)
{
- var requests = new List();
- Task Discover(DiscoverHierarchyRequest req, CancellationToken ct)
+ public List Requests { get; } = [];
+
+ public Task Discover(DiscoverHierarchyRequest req, CancellationToken ct)
{
- requests.Add(req);
+ Requests.Add(req.Clone());
+ var matched = galaxy.Where(o => Matches(o, req)).ToList();
+ var offset = req.PageToken == "" ? 0 : int.Parse(req.PageToken.Split(':')[1]);
var reply = new DiscoverHierarchyReply();
- if (req.PageToken == "")
+ reply.Objects.Add(matched.Skip(offset).Take(req.PageSize));
+ if (offset + req.PageSize < matched.Count)
{
- reply.Objects.Add([Obj("Plat01", "$WinPlatform"), Obj("Tank01", "$UserDefined")]);
- reply.NextPageToken = "p2";
- }
- else
- {
- reply.Objects.Add([Obj("Eng01", "$AppEngine", "$WinPlatform"), Obj("Plat01", "$WinPlatform"), Obj("", "$WinPlatform")]);
+ reply.NextPageToken = $"{string.Join(",", req.TemplateChainContains)}:{offset + req.PageSize}";
}
return Task.FromResult(reply);
}
- var names = await GatewayGalaxyPlatformSource.QueryAsync(Discover, pageSize: 50, CancellationToken.None);
+ private static bool Matches(GalaxyObject obj, DiscoverHierarchyRequest req)
+ {
+ if (req.CategoryIds.Count > 0 && !req.CategoryIds.Contains(obj.CategoryId)) return false;
+ foreach (var filter in req.TemplateChainContains)
+ {
+ if (!obj.TemplateChain.Any(t => t.Contains(filter, StringComparison.OrdinalIgnoreCase))) return false;
+ }
+ return true;
+ }
+ }
- names.ShouldBe(["Plat01", "Eng01"]);
- requests.Count.ShouldBe(2);
- requests.ShouldAllBe(r => r.PageSize == 50 && r.IncludeAttributes == false);
- requests.ShouldAllBe(r => r.TemplateChainContains.Contains("$WinPlatform") && r.TemplateChainContains.Contains("$AppEngine"));
+ ///
+ /// #864: against a gateway that ANDs template filters (as the real one does), every deployed
+ /// $WinPlatform AND every $AppEngine is found — the old single request carrying both
+ /// tokens matched none. Shape of the VD03 DEV galaxy: 3 platforms, 2 engines.
+ ///
+ [Fact]
+ public async Task Finds_every_platform_and_engine_against_an_AND_filtering_gateway()
+ {
+ var gw = new FilteringGateway(
+ Obj("CTRX_DEV", "$WinPlatform"),
+ Obj("DevWinPlatform", "$WinPlatform"),
+ Obj("JoePlatform", "$JoePlatformTemplate", "$WinPlatform"),
+ Obj("DevAppEngine", "$AppEngine"),
+ Obj("JoeAppEngine", "$AppEngine"),
+ Obj("Tank01", "$UserDefined"),
+ Obj("Area01", "$Area"));
+
+ var names = await GatewayGalaxyPlatformSource.QueryAsync(gw.Discover, pageSize: 50, CancellationToken.None);
+
+ names.ShouldBe(["CTRX_DEV", "DevWinPlatform", "JoePlatform", "DevAppEngine", "JoeAppEngine"]);
+ gw.Requests.ShouldAllBe(r => r.TemplateChainContains.Count == 1 && r.PageSize == 50 && r.IncludeAttributes == false);
+ gw.Requests.Select(r => r.TemplateChainContains[0]).Distinct().ShouldBe(["$WinPlatform", "$AppEngine"]);
+ }
+
+ /// Each per-template sweep pages to completion independently; tag names are de-duplicated across sweeps.
+ [Fact]
+ public async Task Each_template_sweep_pages_independently_and_names_are_deduplicated()
+ {
+ var gw = new FilteringGateway(
+ Obj("Plat01", "$WinPlatform"),
+ Obj("Plat02", "$WinPlatform"),
+ Obj("Plat03", "$WinPlatform"),
+ // An object whose chain carries both templates is reported once.
+ Obj("Hybrid01", "$AppEngine", "$WinPlatform"),
+ Obj("Eng01", "$AppEngine"),
+ Obj("Eng02", "$AppEngine"),
+ Obj("", "$WinPlatform"));
+
+ var names = await GatewayGalaxyPlatformSource.QueryAsync(gw.Discover, pageSize: 2, CancellationToken.None);
+
+ names.ShouldBe(["Plat01", "Plat02", "Plat03", "Hybrid01", "Eng01", "Eng02"]);
+ // $WinPlatform: 5 matches (incl. the empty tag) => 3 pages; $AppEngine: 3 matches => 2 pages.
+ gw.Requests.Count(r => r.TemplateChainContains[0] == "$WinPlatform").ShouldBe(3);
+ gw.Requests.Count(r => r.TemplateChainContains[0] == "$AppEngine").ShouldBe(2);
+ }
+
+ /// A gateway that ignores the filter still yields only platforms (replies are re-filtered on the chain).
+ [Fact]
+ public async Task Replies_are_refiltered_on_the_template_chain()
+ {
+ Task Discover(DiscoverHierarchyRequest req, CancellationToken ct)
+ {
+ var reply = new DiscoverHierarchyReply();
+ reply.Objects.Add([Obj("Plat01", "$WinPlatform"), Obj("Tank01", "$UserDefined"), Obj("Eng01", "$AppEngine")]);
+ return Task.FromResult(reply);
+ }
+
+ (await GatewayGalaxyPlatformSource.QueryAsync(Discover, pageSize: 50, CancellationToken.None)).ShouldBe(["Plat01", "Eng01"]);
}
/// A gateway that strips the template chain is trusted to have filtered server-side.