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
This commit is contained in:
+8
-5
@@ -80,14 +80,17 @@ public sealed class GalaxyRepositoryGrpcServiceScopeTests
|
||||
Assert.Equal(6, full.TotalObjectCount);
|
||||
Assert.Equal(4, full.Objects.Sum(obj => obj.Attributes.Count));
|
||||
|
||||
// The glob selects only LineA's two leaf objects (Pump01, Valve01), each with one
|
||||
// attribute. That scoped projection (2 objects / 2 attributes) is a non-empty subset
|
||||
// distinct from both the full count and the raw notifier values below.
|
||||
// The glob allows only LineA's two leaf objects (Pump01, Valve01), each with one
|
||||
// attribute. The projection also returns their two ancestors (PlantArea, LineA) as
|
||||
// attribute-stripped navigation nodes (GalaxyRepository-002); the deploy event must count
|
||||
// only the 2 allowed objects / 2 attributes — a non-empty subset distinct from both the
|
||||
// full count and the raw notifier values below.
|
||||
GalaxyHierarchyQueryResult scopedProjection = GalaxyHierarchyProjector.Project(
|
||||
entry,
|
||||
new DiscoverHierarchyRequest(),
|
||||
browseSubtreeGlobs: ["PlantArea/LineA/*"]);
|
||||
Assert.Equal(2, scopedProjection.TotalObjectCount);
|
||||
Assert.Equal(4, scopedProjection.TotalObjectCount);
|
||||
Assert.Equal(2, scopedProjection.NavigationAncestorCount);
|
||||
Assert.Equal(2, scopedProjection.Objects.Sum(obj => obj.Attributes.Count));
|
||||
|
||||
// Publish a deploy event whose RAW counts differ from both full and scoped, so an
|
||||
@@ -114,7 +117,7 @@ public sealed class GalaxyRepositoryGrpcServiceScopeTests
|
||||
new TestServerCallContext());
|
||||
|
||||
DeployEvent emitted = Assert.Single(responseStream.Written);
|
||||
Assert.Equal(scopedProjection.TotalObjectCount, emitted.ObjectCount);
|
||||
Assert.Equal(2, emitted.ObjectCount);
|
||||
Assert.Equal(2, emitted.AttributeCount);
|
||||
// The raw notifier values were overridden by the scoped re-projection.
|
||||
Assert.NotEqual(999, emitted.ObjectCount);
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user