client/go: LazyBrowseNode walker for lazy hierarchy browse

This commit is contained in:
Joseph Doherty
2026-05-28 14:26:41 -04:00
parent 555e4be51f
commit fd2a0ac4c7
3 changed files with 487 additions and 9 deletions
+322 -9
View File
@@ -9,6 +9,8 @@ import (
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -370,15 +372,18 @@ func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient
type fakeGalaxyServer struct {
pb.UnimplementedGalaxyRepositoryServer
testReply *pb.TestConnectionReply
testAuth string
failTest bool
deployReply *pb.GetLastDeployTimeReply
discoverReply *pb.DiscoverHierarchyReply
watchEvents []*pb.DeployEvent
watchRequest *pb.WatchDeployEventsRequest
watchSendInterval time.Duration
watchHoldOpen bool
testReply *pb.TestConnectionReply
testAuth string
failTest bool
deployReply *pb.GetLastDeployTimeReply
discoverReply *pb.DiscoverHierarchyReply
watchEvents []*pb.DeployEvent
watchRequest *pb.WatchDeployEventsRequest
watchSendInterval time.Duration
watchHoldOpen bool
browseChildrenCalls []*pb.BrowseChildrenRequest
browseChildrenReplies []*pb.BrowseChildrenReply
browseChildrenError error
}
func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) {
@@ -425,3 +430,311 @@ func (s *fakeGalaxyServer) WatchDeployEvents(req *pb.WatchDeployEventsRequest, s
}
return nil
}
func (s *fakeGalaxyServer) BrowseChildren(ctx context.Context, req *pb.BrowseChildrenRequest) (*pb.BrowseChildrenReply, error) {
s.browseChildrenCalls = append(s.browseChildrenCalls, req)
if s.browseChildrenError != nil {
err := s.browseChildrenError
s.browseChildrenError = nil
return nil, err
}
if len(s.browseChildrenReplies) == 0 {
return &pb.BrowseChildrenReply{}, nil
}
reply := s.browseChildrenReplies[0]
s.browseChildrenReplies = s.browseChildrenReplies[1:]
return reply, nil
}
func obj(id int32, tag string, isArea bool) *pb.GalaxyObject {
return &pb.GalaxyObject{
GobjectId: id,
TagName: tag,
BrowseName: tag,
IsArea: isArea,
}
}
func buildBrowseReply(children []*pb.GalaxyObject, hasChildren []bool, seq uint64) *pb.BrowseChildrenReply {
return &pb.BrowseChildrenReply{
TotalChildCount: int32(len(children)),
CacheSequence: seq,
Children: children,
ChildHasChildren: hasChildren,
}
}
func TestGalaxyBrowseNoParentReturnsRoots(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true), obj(99, "Other", false)},
[]bool{true, false},
7,
),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
if got, want := len(roots), 2; got != want {
t.Fatalf("len(roots) = %d, want %d", got, want)
}
if roots[0].Object().GetTagName() != "Plant" {
t.Fatalf("roots[0].TagName = %q", roots[0].Object().GetTagName())
}
if !roots[0].HasChildrenHint() {
t.Fatal("roots[0].HasChildrenHint = false, want true")
}
if roots[0].IsExpanded() {
t.Fatal("roots[0].IsExpanded = true, want false")
}
if roots[1].HasChildrenHint() {
t.Fatal("roots[1].HasChildrenHint = true, want false")
}
if len(fake.browseChildrenCalls) != 1 {
t.Fatalf("BrowseChildren calls = %d, want 1", len(fake.browseChildrenCalls))
}
if fake.browseChildrenCalls[0].GetParent() != nil {
t.Fatalf("root browse should not set Parent oneof, got %T", fake.browseChildrenCalls[0].GetParent())
}
}
func TestGalaxyBrowseExpandPopulatesChildrenAndMarksExpanded(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
1,
),
buildBrowseReply(
[]*pb.GalaxyObject{obj(10, "Area1", true), obj(11, "Tank1", false)},
[]bool{true, false},
1,
),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
if len(roots) != 1 {
t.Fatalf("len(roots) = %d, want 1", len(roots))
}
plant := roots[0]
if plant.IsExpanded() {
t.Fatal("plant.IsExpanded = true before Expand, want false")
}
if err := plant.Expand(context.Background()); err != nil {
t.Fatalf("Expand: %v", err)
}
if !plant.IsExpanded() {
t.Fatal("plant.IsExpanded = false after Expand, want true")
}
children := plant.Children()
if len(children) != 2 {
t.Fatalf("len(children) = %d, want 2", len(children))
}
if children[0].Object().GetTagName() != "Area1" {
t.Fatalf("children[0].TagName = %q, want Area1", children[0].Object().GetTagName())
}
if !children[0].HasChildrenHint() {
t.Fatal("children[0].HasChildrenHint = false, want true")
}
if children[1].HasChildrenHint() {
t.Fatal("children[1].HasChildrenHint = true, want false")
}
if len(fake.browseChildrenCalls) != 2 {
t.Fatalf("BrowseChildren calls = %d, want 2", len(fake.browseChildrenCalls))
}
parent := fake.browseChildrenCalls[1].GetParent()
parentGobj, ok := parent.(*pb.BrowseChildrenRequest_ParentGobjectId)
if !ok {
t.Fatalf("Parent oneof = %T, want *BrowseChildrenRequest_ParentGobjectId", parent)
}
if parentGobj.ParentGobjectId != 1 {
t.Fatalf("ParentGobjectId = %d, want 1", parentGobj.ParentGobjectId)
}
}
func TestGalaxyBrowseExpandIdempotentNoSecondRpc(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
1,
),
buildBrowseReply(
[]*pb.GalaxyObject{obj(10, "Area1", true)},
[]bool{false},
1,
),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
plant := roots[0]
if err := plant.Expand(context.Background()); err != nil {
t.Fatalf("Expand #1: %v", err)
}
callsAfterFirst := len(fake.browseChildrenCalls)
if callsAfterFirst != 2 {
t.Fatalf("BrowseChildren calls after first Expand = %d, want 2", callsAfterFirst)
}
if err := plant.Expand(context.Background()); err != nil {
t.Fatalf("Expand #2: %v", err)
}
if got := len(fake.browseChildrenCalls); got != callsAfterFirst {
t.Fatalf("BrowseChildren calls after second Expand = %d, want %d (no extra RPC)", got, callsAfterFirst)
}
}
func TestGalaxyBrowseExpandUnknownParentReturnsNotFoundError(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
1,
),
},
browseChildrenError: status.Error(codes.NotFound, "parent not found"),
}
// The first Browse() consumes the first reply; the next call (Expand) will
// then hit browseChildrenError. We need the error to fire only on the second
// call, so seed the reply first and let the call sequence consume them in
// order. Because BrowseChildren in the fake consumes browseChildrenError
// before falling through to replies, swap the strategy: keep the root reply
// but have BrowseChildren return the error on the second call. We do this by
// emptying the reply list after the first Browse.
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
// First call returns the error (because browseChildrenError takes precedence).
// To avoid that, clear it for the root call by performing a manual setup: we
// pre-stage replies first, then set the error after the first call. Easiest:
// pre-Browse() with error=nil, then set error before Expand.
fake.browseChildrenError = nil
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
if len(roots) != 1 {
t.Fatalf("len(roots) = %d, want 1", len(roots))
}
fake.browseChildrenError = status.Error(codes.NotFound, "parent not found")
err = roots[0].Expand(context.Background())
if err == nil {
t.Fatal("Expand: error = nil, want NotFound")
}
if status.Code(err) != codes.NotFound {
t.Fatalf("status.Code = %s, want NotFound", status.Code(err))
}
if roots[0].IsExpanded() {
t.Fatal("roots[0].IsExpanded = true after failed Expand, want false")
}
}
func TestGalaxyBrowseExpandMultiPageGathersAllPages(t *testing.T) {
firstPage := buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
7,
)
pageA := buildBrowseReply(
[]*pb.GalaxyObject{obj(10, "Child1", false), obj(11, "Child2", false)},
[]bool{false, false},
7,
)
pageA.NextPageToken = "7:abc:2"
pageB := buildBrowseReply(
[]*pb.GalaxyObject{obj(12, "Child3", false)},
[]bool{false},
7,
)
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{firstPage, pageA, pageB},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
if err := roots[0].Expand(context.Background()); err != nil {
t.Fatalf("Expand: %v", err)
}
children := roots[0].Children()
if len(children) != 3 {
t.Fatalf("len(children) = %d, want 3", len(children))
}
if len(fake.browseChildrenCalls) != 3 {
t.Fatalf("BrowseChildren calls = %d, want 3", len(fake.browseChildrenCalls))
}
if got := fake.browseChildrenCalls[2].GetPageToken(); got != "7:abc:2" {
t.Fatalf("third call PageToken = %q, want %q", got, "7:abc:2")
}
}
func TestGalaxyBrowseWithFilterForwardsToRequest(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(nil, nil, 1),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
include := true
opts := &BrowseChildrenOptions{
CategoryIds: []int32{7, 9},
TemplateChainContains: []string{"$AppObject"},
TagNameGlob: "Tank*",
IncludeAttributes: &include,
AlarmBearingOnly: true,
HistorizedOnly: true,
}
if _, err := client.Browse(context.Background(), opts); err != nil {
t.Fatalf("Browse: %v", err)
}
if len(fake.browseChildrenCalls) != 1 {
t.Fatalf("BrowseChildren calls = %d, want 1", len(fake.browseChildrenCalls))
}
got := fake.browseChildrenCalls[0]
if want := []int32{7, 9}; len(got.GetCategoryIds()) != 2 || got.GetCategoryIds()[0] != want[0] || got.GetCategoryIds()[1] != want[1] {
t.Fatalf("CategoryIds = %v, want %v", got.GetCategoryIds(), want)
}
if want := []string{"$AppObject"}; len(got.GetTemplateChainContains()) != 1 || got.GetTemplateChainContains()[0] != want[0] {
t.Fatalf("TemplateChainContains = %v, want %v", got.GetTemplateChainContains(), want)
}
if got.GetTagNameGlob() != "Tank*" {
t.Fatalf("TagNameGlob = %q, want %q", got.GetTagNameGlob(), "Tank*")
}
if !got.GetIncludeAttributes() {
t.Fatal("IncludeAttributes = false, want true")
}
if !got.GetAlarmBearingOnly() {
t.Fatal("AlarmBearingOnly = false, want true")
}
if !got.GetHistorizedOnly() {
t.Fatal("HistorizedOnly = false, want true")
}
}