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.