From b33cf1c80d0b14305af5b3d627f85088d050c646 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 8 Jun 2026 12:29:52 -0400 Subject: [PATCH] feat(uns): lazy per-equipment tag + virtual-tag load Add LoadEquipmentChildrenAsync to IUnsTreeService and UnsTreeService; returns Tag nodes (ordered by Name) then VirtualTag nodes (ordered by Name) as leaf nodes with ChildCount=0, HasLazyChildren=false, keys tag:{id}/vtag:{id}. --- .../Uns/IUnsTreeService.cs | 12 ++- .../Uns/UnsTreeService.cs | 45 ++++++++++ .../Uns/UnsTreeServiceLazyTests.cs | 82 +++++++++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLazyTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs index d11cc27f..fdf1c147 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs @@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; /// Loads the structural portion of the unified-namespace (UNS) browse tree — /// Enterprise → Cluster → Area → Line → Equipment — from the config database. /// Equipment children (tags/virtual tags) are summarised by count only and loaded -/// lazily by the renderer; this service never returns Tag/VirtualTag leaf nodes. +/// lazily by the renderer via . /// public interface IUnsTreeService { @@ -16,4 +16,14 @@ public interface IUnsTreeService /// A token to cancel the load. /// The enterprise root nodes, each populated down to equipment. Task> LoadStructureAsync(CancellationToken ct = default); + + /// + /// Lazily loads the Tag and VirtualTag leaf nodes for a single equipment node. + /// Tags are returned first (ordered by Name), followed by VirtualTags (ordered by Name). + /// Leaf nodes carry ChildCount = 0 and HasLazyChildren = false. + /// + /// The equipment whose children to load. + /// A token to cancel the load. + /// Tag nodes followed by VirtualTag nodes; empty if the equipment has none. + Task> LoadEquipmentChildrenAsync(string equipmentId, CancellationToken ct = default); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs index 35913425..7096688b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs @@ -76,4 +76,49 @@ public sealed class UnsTreeService(IDbContextFactory dbF return UnsTreeAssembly.Build(clusters, areas, lines, equipment); } + + /// + public async Task> LoadEquipmentChildrenAsync( + string equipmentId, + CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var tagNodes = await db.Tags + .AsNoTracking() + .Where(t => t.EquipmentId == equipmentId) + .OrderBy(t => t.Name) + .Select(t => new UnsNode + { + Kind = UnsNodeKind.Tag, + Key = $"tag:{t.TagId}", + DisplayName = $"{t.Name} ({t.DataType})", + EntityId = t.TagId, + ClusterId = null, + ChildCount = 0, + HasLazyChildren = false, + }) + .ToListAsync(ct); + + var vtagNodes = await db.VirtualTags + .AsNoTracking() + .Where(v => v.EquipmentId == equipmentId) + .OrderBy(v => v.Name) + .Select(v => new UnsNode + { + Kind = UnsNodeKind.VirtualTag, + Key = $"vtag:{v.VirtualTagId}", + DisplayName = $"{v.Name} (VirtualTag)", + EntityId = v.VirtualTagId, + ClusterId = null, + ChildCount = 0, + HasLazyChildren = false, + }) + .ToListAsync(ct); + + var result = new List(tagNodes.Count + vtagNodes.Count); + result.AddRange(tagNodes); + result.AddRange(vtagNodes); + return result; + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLazyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLazyTests.cs new file mode 100644 index 00000000..f6403580 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLazyTests.cs @@ -0,0 +1,82 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Verifies returns Tag leaf nodes +/// followed by VirtualTag leaf nodes, in Name order, with the correct keys and display names. +/// +[Trait("Category", "Unit")] +public sealed class UnsTreeServiceLazyTests +{ + private static UnsTreeService SeededService() + { + var dbName = $"uns-lazy-{Guid.NewGuid():N}"; + UnsTreeTestDb.SeedNamed(dbName); + return new UnsTreeService(UnsTreeTestDb.Factory(dbName)); + } + + /// + /// Tags come first, ordered by Name, then VirtualTags; keys follow the tag:/vtag: scheme + /// and the Tag display name embeds the DataType. + /// + [Fact] + public async Task LoadEquipmentChildren_returns_tags_then_vtags() + { + var service = SeededService(); + + var children = await service.LoadEquipmentChildrenAsync(UnsTreeTestDb.SeededEquipmentId); + + // Seed has 2 tags (speed=Float, running=Boolean) + 1 vtag (computed). + // Tags are ordered by Name: "running" < "speed". + children.Count.ShouldBe(3); + + var running = children[0]; + running.Kind.ShouldBe(UnsNodeKind.Tag); + running.Key.ShouldBe("tag:TAG-2"); + running.EntityId.ShouldBe("TAG-2"); + running.ClusterId.ShouldBeNull(); + running.DisplayName.ShouldContain("running"); + running.DisplayName.ShouldContain("Boolean"); + running.ChildCount.ShouldBe(0); + running.HasLazyChildren.ShouldBeFalse(); + running.Children.ShouldBeEmpty(); + + var speed = children[1]; + speed.Kind.ShouldBe(UnsNodeKind.Tag); + speed.Key.ShouldBe("tag:TAG-1"); + speed.EntityId.ShouldBe("TAG-1"); + speed.DisplayName.ShouldContain("speed"); + speed.DisplayName.ShouldContain("Float"); + + var vtag = children[2]; + vtag.Kind.ShouldBe(UnsNodeKind.VirtualTag); + vtag.Key.ShouldBe("vtag:VTAG-1"); + vtag.EntityId.ShouldBe("VTAG-1"); + vtag.ClusterId.ShouldBeNull(); + vtag.DisplayName.ShouldContain("computed"); + vtag.DisplayName.ShouldContain("VirtualTag"); + vtag.ChildCount.ShouldBe(0); + vtag.HasLazyChildren.ShouldBeFalse(); + vtag.Children.ShouldBeEmpty(); + } + + /// An equipment with no tags or virtual tags returns an empty list. + [Fact] + public async Task LoadEquipmentChildren_empty_for_equipment_with_none() + { + // Use a fresh named store and add an equipment with no tags/vtags. + var dbName = $"uns-lazy-empty-{Guid.NewGuid():N}"; + UnsTreeTestDb.SeedNamed(dbName); + + // The orphan equipment id is not in the seeded fixture, so just use a novel id. + const string emptyEquipmentId = "EQ-NO-TAGS"; + var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); + + var children = await service.LoadEquipmentChildrenAsync(emptyEquipmentId); + + children.ShouldBeEmpty(); + } +}