Files
scadaproj/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/ScopedAncestorBrowseTests.cs
T
Joseph Doherty 414050935e fix(galaxyrepository): scoped browse surfaces ancestors as navigation-only nodes (scadaproj#42)
A browse-subtree scope such as Plant/LineA/* filtered out Plant and
Plant/LineA, so scoped lazy browse from the root was empty and
DiscoverHierarchy returned orphans. Out-of-scope ancestors of an allowed
object are now returned as navigation-only containers: attributes stripped,
child_has_children = true, and only their children on the path to an allowed
object are listed (siblings stay hidden). DiscoverHierarchy reports them in
GalaxyHierarchyQueryResult.NavigationAncestorCount; WatchDeployEvents
subtracts it so its scoped counts stay allowed-objects-only.

- tests: ScopedAncestorBrowseTests; deploy-event scope test updated
- IGalaxyBrowseScopeProvider documents the semantics; CLAUDE.md 0.2.1 notes
- code-reviews: GalaxyRepository-002 Resolved; README regenerated

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 14:48:02 -05:00

132 lines
4.9 KiB
C#

using ZB.MOM.WW.GalaxyRepository;
using ZB.MOM.WW.GalaxyRepository.Grpc;
namespace ZB.MOM.WW.GalaxyRepository.Tests;
/// <summary>
/// GalaxyRepository-002 (scadaproj#42): a browse-subtree scope such as <c>Plant/LineA/*</c>
/// hid the ancestors of the allowed subtree, so scoped lazy browse from the root was empty.
/// Ancestors must now surface as attribute-stripped navigation containers, while their
/// non-allowed children stay hidden.
/// </summary>
public sealed class ScopedAncestorBrowseTests
{
private static readonly string[] Scope = ["Plant/LineA/*"];
[Fact]
public void Root_browse_surfaces_the_ancestor_as_a_navigation_container()
{
GalaxyBrowseChildrenResult result = Browse(new BrowseChildrenRequest());
GalaxyObject plant = Assert.Single(result.Children);
Assert.Equal("Plant", plant.TagName);
Assert.True(result.ChildHasChildren[0]);
Assert.Empty(plant.Attributes);
Assert.Equal(1, result.TotalChildCount);
}
[Fact]
public void Ancestor_browse_shows_only_the_path_to_the_allowed_subtree()
{
GalaxyBrowseChildrenResult result = Browse(new BrowseChildrenRequest { ParentContainedPath = "Plant" });
GalaxyObject lineA = Assert.Single(result.Children);
Assert.Equal("Plant.LineA", lineA.TagName);
Assert.True(result.ChildHasChildren[0]);
Assert.Empty(lineA.Attributes);
}
[Fact]
public void Allowed_subtree_is_returned_with_its_attributes()
{
GalaxyBrowseChildrenResult result = Browse(new BrowseChildrenRequest { ParentContainedPath = "Plant/LineA" });
GalaxyObject pump = Assert.Single(result.Children);
Assert.Equal("Plant.LineA.Pump01", pump.TagName);
Assert.Single(pump.Attributes);
}
[Fact]
public void Non_allowed_sibling_subtree_does_not_leak()
{
GalaxyBrowseChildrenResult result = Browse(new BrowseChildrenRequest { ParentContainedPath = "Plant/LineB" });
Assert.Empty(result.Children);
Assert.Equal(0, result.TotalChildCount);
}
[Fact]
public void Ancestor_is_not_surfaced_when_no_allowed_descendant_matches_the_request_filter()
{
GalaxyBrowseChildrenResult result = Browse(new BrowseChildrenRequest { TagNameGlob = "*Nothing*" });
Assert.Empty(result.Children);
}
[Fact]
public void DiscoverHierarchy_includes_stripped_ancestors_and_no_other_objects()
{
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(
CreateEntry(), new DiscoverHierarchyRequest(), Scope);
Assert.Equal(
new[] { "Plant", "Plant.LineA", "Plant.LineA.Pump01" },
result.Objects.Select(o => o.TagName).Order(StringComparer.Ordinal));
Assert.Empty(result.Objects.Single(o => o.TagName == "Plant").Attributes);
Assert.Empty(result.Objects.Single(o => o.TagName == "Plant.LineA").Attributes);
Assert.Single(result.Objects.Single(o => o.TagName == "Plant.LineA.Pump01").Attributes);
Assert.Equal(2, result.NavigationAncestorCount);
}
[Fact]
public void Unscoped_DiscoverHierarchy_is_unchanged()
{
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(CreateEntry(), new DiscoverHierarchyRequest());
Assert.Equal(6, result.TotalObjectCount);
Assert.Equal(0, result.NavigationAncestorCount);
Assert.Single(result.Objects.Single(o => o.TagName == "Plant").Attributes);
}
private static GalaxyBrowseChildrenResult Browse(BrowseChildrenRequest request) =>
GalaxyBrowseProjector.ProjectChildren(CreateEntry(), request, Scope, 0, 100);
private static GalaxyHierarchyCacheEntry CreateEntry()
{
GalaxyObject[] objects =
[
Obj(1, 0, "Plant", "Plant", isArea: true),
Obj(2, 1, "LineA", "Plant.LineA", isArea: true),
Obj(3, 2, "Pump01", "Plant.LineA.Pump01"),
Obj(4, 1, "LineB", "Plant.LineB", isArea: true),
Obj(5, 4, "Pump02", "Plant.LineB.Pump02"),
Obj(6, 1, "Loose", "Plant.Loose"),
];
return GalaxyHierarchyCacheEntry.Empty with
{
Status = GalaxyCacheStatus.Healthy,
Sequence = 1,
LastSuccessAt = DateTimeOffset.UtcNow,
Objects = objects,
Index = GalaxyHierarchyIndex.Build(objects),
ObjectCount = objects.Length,
};
}
private static GalaxyObject Obj(int id, int parent, string name, string tag, bool isArea = false)
{
GalaxyObject obj = new()
{
GobjectId = id,
ParentGobjectId = parent,
IsArea = isArea,
ContainedName = name,
BrowseName = name,
TagName = tag,
};
// Every object, ancestors included, carries an attribute so stripping is observable.
obj.Attributes.Add(new GalaxyAttribute { AttributeName = "Secret", FullTagReference = tag + ".Secret" });
return obj;
}
}