From cec670f0c870a41900dd4556edb3fcf123b21018 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 8 Jun 2026 12:23:00 -0400 Subject: [PATCH] feat(uns): IUnsTreeService structural load + DI registration --- .../EndpointRouteBuilderExtensions.cs | 2 + .../Uns/IUnsTreeService.cs | 19 +++ .../Uns/UnsTreeService.cs | 78 +++++++++ .../Uns/UnsTreeServiceStructureTests.cs | 94 +++++++++++ .../Uns/UnsTreeTestDb.cs | 149 ++++++++++++++++++ .../ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj | 1 + 6 files changed, 343 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceStructureTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeTestDb.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index eafdbd44..1de32072 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; using ZB.MOM.WW.OtOpcUa.Commons.Browsing; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser; using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser; @@ -42,6 +43,7 @@ public static class EndpointRouteBuilderExtensions services.AddSingleton(); services.AddHostedService(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs new file mode 100644 index 00000000..d11cc27f --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs @@ -0,0 +1,19 @@ +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. +/// +public interface IUnsTreeService +{ + /// + /// Loads the full structural tree. Empty clusters are retained so they remain + /// visible and editable. The returned nodes are detached view-models, safe to + /// hold and mutate UI state on after the underlying context is disposed. + /// + /// A token to cancel the load. + /// The enterprise root nodes, each populated down to equipment. + Task> LoadStructureAsync(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 new file mode 100644 index 00000000..e14b495e --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// Default . Reads the structural rows with a handful of +/// untracked queries, computes per-equipment tag/virtual-tag counts, and hands the flat +/// rows to the pure to nest into the browse tree. +/// +/// +/// A new context is created per call via the pooled factory — the same pattern every +/// AdminUI page uses — so the service is safe to register as a scoped singleton and call +/// concurrently from independent Blazor circuits. +/// +public sealed class UnsTreeService(IDbContextFactory dbFactory) : IUnsTreeService +{ + /// + public async Task> LoadStructureAsync(CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var clusters = await db.ServerClusters + .AsNoTracking() + .Select(c => new ClusterRow(c.ClusterId, c.Enterprise, c.Site, c.Name)) + .ToListAsync(ct); + + var areas = await db.UnsAreas + .AsNoTracking() + .Select(a => new AreaRow(a.UnsAreaId, a.ClusterId, a.Name)) + .ToListAsync(ct); + + var lines = await db.UnsLines + .AsNoTracking() + .Select(l => new LineRow(l.UnsLineId, l.UnsAreaId, l.Name)) + .ToListAsync(ct); + + var equipmentRows = await db.Equipment + .AsNoTracking() + .Select(e => new + { + e.EquipmentId, + e.UnsLineId, + e.MachineCode, + e.Name, + }) + .ToListAsync(ct); + + // Per-equipment driver-tag counts (tags with no equipment are excluded). + var tagCounts = (await db.Tags + .AsNoTracking() + .Where(t => t.EquipmentId != null) + .GroupBy(t => t.EquipmentId) + .Select(g => new { EquipmentId = g.Key!, Count = g.Count() }) + .ToListAsync(ct)) + .ToDictionary(x => x.EquipmentId, x => x.Count, StringComparer.Ordinal); + + // Per-equipment virtual-tag counts (EquipmentId is always set on virtual tags). + var vtagCounts = (await db.VirtualTags + .AsNoTracking() + .GroupBy(v => v.EquipmentId) + .Select(g => new { EquipmentId = g.Key, Count = g.Count() }) + .ToListAsync(ct)) + .ToDictionary(x => x.EquipmentId, x => x.Count, StringComparer.Ordinal); + + var equipment = equipmentRows + .Select(e => new EquipmentRow( + e.EquipmentId, + e.UnsLineId, + e.MachineCode, + e.Name, + tagCounts.GetValueOrDefault(e.EquipmentId), + vtagCounts.GetValueOrDefault(e.EquipmentId))) + .ToList(); + + return UnsTreeAssembly.Build(clusters, areas, lines, equipment); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceStructureTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceStructureTests.cs new file mode 100644 index 00000000..e4c19c21 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceStructureTests.cs @@ -0,0 +1,94 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Verifies builds the Enterprise→Cluster→ +/// Area→Line→Equipment structure from the config database, including per-equipment tag/ +/// virtual-tag counts and the retention of empty clusters. +/// +[Trait("Category", "Unit")] +public sealed class UnsTreeServiceStructureTests +{ + private static UnsTreeService SeededService() + { + var dbName = $"uns-{Guid.NewGuid():N}"; + UnsTreeTestDb.SeedNamed(dbName); + return new UnsTreeService(UnsTreeTestDb.Factory(dbName)); + } + + /// Root enterprise "zb" carries both clusters, and MAIN exposes the full + /// area→line→equipment path with the expected kinds and keys. + [Fact] + public async Task LoadStructure_builds_full_hierarchy() + { + var service = SeededService(); + + var roots = await service.LoadStructureAsync(); + + var enterprise = roots.ShouldHaveSingleItem(); + enterprise.Kind.ShouldBe(UnsNodeKind.Enterprise); + enterprise.DisplayName.ShouldBe("zb"); + enterprise.Children.Count.ShouldBe(2); + enterprise.Children.ShouldAllBe(c => c.Kind == UnsNodeKind.Cluster); + + var main = enterprise.Children.Single(c => c.ClusterId == UnsTreeTestDb.PopulatedClusterId); + main.Kind.ShouldBe(UnsNodeKind.Cluster); + + var area = main.Children.ShouldHaveSingleItem(); + area.Kind.ShouldBe(UnsNodeKind.Area); + area.Key.ShouldBe("area:AREA-1"); + area.EntityId.ShouldBe("AREA-1"); + + var line = area.Children.ShouldHaveSingleItem(); + line.Kind.ShouldBe(UnsNodeKind.Line); + line.Key.ShouldBe("line:LINE-1"); + line.EntityId.ShouldBe("LINE-1"); + + var equipment = line.Children.ShouldHaveSingleItem(); + equipment.Kind.ShouldBe(UnsNodeKind.Equipment); + equipment.Key.ShouldBe($"eq:{UnsTreeTestDb.SeededEquipmentId}"); + equipment.EntityId.ShouldBe(UnsTreeTestDb.SeededEquipmentId); + equipment.ClusterId.ShouldBe(UnsTreeTestDb.PopulatedClusterId); + } + + /// The seeded equipment node's badge count equals tags + virtual tags and it is + /// flagged as lazily expandable. + [Fact] + public async Task LoadStructure_counts_tags_and_vtags_per_equipment() + { + var service = SeededService(); + + var roots = await service.LoadStructureAsync(); + + var equipment = roots + .Single() + .Children.Single(c => c.ClusterId == UnsTreeTestDb.PopulatedClusterId) + .Children.Single() + .Children.Single() + .Children.Single(); + + // Seed: 2 driver tags + 1 virtual tag (the orphan tag has no equipment and is excluded). + equipment.ChildCount.ShouldBe(3); + equipment.HasLazyChildren.ShouldBeTrue(); + } + + /// An empty cluster (no areas) is still rendered as a Cluster node with no children. + [Fact] + public async Task LoadStructure_includes_empty_clusters() + { + var service = SeededService(); + + var roots = await service.LoadStructureAsync(); + + var siteA = roots + .Single() + .Children.Single(c => c.ClusterId == UnsTreeTestDb.EmptyClusterId); + + siteA.Kind.ShouldBe(UnsNodeKind.Cluster); + siteA.Children.ShouldBeEmpty(); + siteA.ChildCount.ShouldBe(0); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeTestDb.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeTestDb.cs new file mode 100644 index 00000000..ad4c9d6f --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeTestDb.cs @@ -0,0 +1,149 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Shared in-memory fixture for UnsTreeService structural tests. Builds an +/// over a named InMemory database and seeds a small, +/// deterministic UNS hierarchy: enterprise "zb" across two clusters (MAIN populated, +/// SITE-A intentionally empty), plus tags and a virtual tag on one equipment node so +/// the per-equipment count joins can be exercised. +/// +internal static class UnsTreeTestDb +{ + /// The equipment that carries the seeded tags and virtual tag. + public const string SeededEquipmentId = "EQ-000000000001"; + + /// The cluster that has no areas, used to cover the empty-cluster case. + public const string EmptyClusterId = "SITE-A"; + + /// The populated cluster with the area→line→equipment path. + public const string PopulatedClusterId = "MAIN"; + + /// Creates a context over a fresh, uniquely-named InMemory database. + public static OtOpcUaConfigDbContext Create() => CreateNamed($"uns-{Guid.NewGuid():N}"); + + /// Creates a context bound to the supplied InMemory database name. + public static OtOpcUaConfigDbContext CreateNamed(string name) => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(name) + .Options); + + /// + /// Returns an whose contexts all share the + /// supplied InMemory database name, so data seeded by is visible + /// to the service under test. + /// + public static IDbContextFactory Factory(string name) => new NamedFactory(name); + + /// Seeds the fixture into the supplied (already-bound) context and saves. + public static void Seed(OtOpcUaConfigDbContext db) + { + // Two clusters under the same enterprise; only MAIN gets a hierarchy. + db.ServerClusters.Add(new ServerCluster + { + ClusterId = PopulatedClusterId, + Name = "Main", + Enterprise = "zb", + Site = "warsaw-west", + RedundancyMode = RedundancyMode.None, + CreatedBy = "test", + }); + db.ServerClusters.Add(new ServerCluster + { + ClusterId = EmptyClusterId, + Name = "Site A", + Enterprise = "zb", + Site = "site-a", + RedundancyMode = RedundancyMode.None, + CreatedBy = "test", + }); + + // MAIN: one area → one line → one equipment. + db.UnsAreas.Add(new UnsArea + { + UnsAreaId = "AREA-1", + ClusterId = PopulatedClusterId, + Name = "assembly", + }); + db.UnsLines.Add(new UnsLine + { + UnsLineId = "LINE-1", + UnsAreaId = "AREA-1", + Name = "line-a", + }); + db.Equipment.Add(new Equipment + { + EquipmentId = SeededEquipmentId, + EquipmentUuid = Guid.NewGuid(), + UnsLineId = "LINE-1", + Name = "machine-1", + MachineCode = "machine_001", + }); + + // Two driver tags + one virtual tag on the seeded equipment → ChildCount 3. + db.Tags.Add(new Tag + { + TagId = "TAG-1", + DriverInstanceId = "DRV-1", + EquipmentId = SeededEquipmentId, + Name = "speed", + DataType = "Float", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{}", + }); + db.Tags.Add(new Tag + { + TagId = "TAG-2", + DriverInstanceId = "DRV-1", + EquipmentId = SeededEquipmentId, + Name = "running", + DataType = "Boolean", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{}", + }); + // A tag with no equipment must be ignored by the count query. + db.Tags.Add(new Tag + { + TagId = "TAG-ORPHAN", + DriverInstanceId = "DRV-1", + EquipmentId = null, + Name = "orphan", + DataType = "Int32", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{}", + }); + db.VirtualTags.Add(new VirtualTag + { + VirtualTagId = "VTAG-1", + EquipmentId = SeededEquipmentId, + Name = "computed", + DataType = "Double", + ScriptId = "SCRIPT-1", + }); + + db.SaveChanges(); + } + + /// Seeds the fixture into a context bound to the supplied InMemory database name. + public static void SeedNamed(string name) + { + using var db = CreateNamed(name); + Seed(db); + } + + /// + /// Minimal that hands back contexts sharing a + /// single InMemory database name — the test-side stand-in for the runtime pooled factory. + /// + private sealed class NamedFactory(string name) : IDbContextFactory + { + public OtOpcUaConfigDbContext CreateDbContext() => CreateNamed(name); + + public Task CreateDbContextAsync(CancellationToken cancellationToken = default) => + Task.FromResult(CreateNamed(name)); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj index 18546af9..a2e4a9d3 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj @@ -10,6 +10,7 @@ + all