diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsNode.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsNode.cs new file mode 100644 index 00000000..fcb16e5d --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsNode.cs @@ -0,0 +1,242 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// The kind of node within the unified-namespace (UNS) browse tree. Determines +/// how the renderer styles the row and which entity (if any) it links to. +/// +public enum UnsNodeKind +{ + Enterprise, + Cluster, + Area, + Line, + Equipment, + Tag, + VirtualTag, +} + +/// +/// View-model for a single node in the UNS browse tree. Carries the stable +/// identity, display text and lazy-load metadata the renderer needs, plus +/// transient UI state (expansion/loading) that is never persisted. +/// +public sealed class UnsNode +{ + /// The kind of node — drives styling and entity linking. + public required UnsNodeKind Kind { get; init; } + + /// Stable per-node identifier, unique within the tree (e.g. eq:{equipmentId}). + public required string Key { get; init; } + + /// Human-readable label shown in the tree. + public required string DisplayName { get; init; } + + /// Owning cluster id for Area/Line/Equipment (and the cluster itself); null for Enterprise. + public string? ClusterId { get; init; } + + /// This node's own logical entity id (UnsAreaId/UnsLineId/EquipmentId/TagId/VirtualTagId); null for Enterprise. + public string? EntityId { get; init; } + + /// Badge count. For equipment this is tag + virtual-tag count; for container nodes it is the direct child count. + public int ChildCount { get; set; } + + /// True when this node has children that are loaded lazily (equipment with > 0). + public bool HasLazyChildren { get; init; } + + /// Eagerly-materialised children. Empty for lazy-loaded equipment until expanded. + public List Children { get; } = new(); + + // --- Runtime UI state (not persisted) --- + + /// Whether the node is currently expanded in the UI. + public bool Expanded { get; set; } + + /// Whether the node's lazy children have been loaded. + public bool Loaded { get; set; } + + /// Whether a lazy-load is currently in flight for this node. + public bool Loading { get; set; } + + /// Last load error message, if any. + public string? Error { get; set; } +} + +/// Flat structural row describing a cluster and its enterprise/site placement. +public readonly record struct ClusterRow(string ClusterId, string Enterprise, string Site, string Name); + +/// Flat structural row describing a UNS area and its owning cluster. +public readonly record struct AreaRow(string UnsAreaId, string ClusterId, string Name); + +/// Flat structural row describing a UNS line and its owning area. +public readonly record struct LineRow(string UnsLineId, string UnsAreaId, string Name); + +/// Flat structural row describing an equipment node, its owning line and its tag/virtual-tag counts. +public readonly record struct EquipmentRow( + string EquipmentId, + string UnsLineId, + string MachineCode, + string Name, + int TagCount, + int VirtualTagCount); + +/// +/// Pure, EF-free assembly of the UNS browse tree from flat structural rows. +/// Builds Enterprise → Cluster → Area → Line → Equipment with deterministic +/// ordinal ordering and equipment lazy-load metadata. +/// +public static class UnsTreeAssembly +{ + /// + /// Builds the Enterprise→Cluster→Area→Line→Equipment tree. Empty clusters + /// (and enterprises whose clusters have no areas) are retained. Ordering is + /// deterministic and ordinal at every level. + /// + public static IReadOnlyList Build( + IReadOnlyList clusters, + IReadOnlyList areas, + IReadOnlyList lines, + IReadOnlyList equipment) + { + // Index children by their parent key for O(1) lookup during nesting. + var areasByCluster = areas + .GroupBy(a => a.ClusterId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); + + var linesByArea = lines + .GroupBy(l => l.UnsAreaId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); + + var equipmentByLine = equipment + .GroupBy(e => e.UnsLineId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); + + // Top level: distinct enterprises, ordered ordinally. + var enterprises = clusters + .GroupBy(c => c.Enterprise, StringComparer.Ordinal) + .OrderBy(g => g.Key, StringComparer.Ordinal) + .Select(entGroup => + { + var clusterNodes = entGroup + .OrderBy(c => c.Name, StringComparer.Ordinal) + .ThenBy(c => c.ClusterId, StringComparer.Ordinal) + .Select(c => BuildCluster(c, areasByCluster, linesByArea, equipmentByLine)) + .ToList(); + + var ent = new UnsNode + { + Kind = UnsNodeKind.Enterprise, + Key = $"ent:{entGroup.Key}", + DisplayName = entGroup.Key, + ClusterId = null, + EntityId = null, + HasLazyChildren = false, + }; + ent.Children.AddRange(clusterNodes); + ent.ChildCount = ent.Children.Count; + return ent; + }) + .ToList(); + + return enterprises; + } + + private static UnsNode BuildCluster( + ClusterRow cluster, + IReadOnlyDictionary> areasByCluster, + IReadOnlyDictionary> linesByArea, + IReadOnlyDictionary> equipmentByLine) + { + var areaNodes = (areasByCluster.TryGetValue(cluster.ClusterId, out var clusterAreas) + ? clusterAreas + : Enumerable.Empty()) + .OrderBy(a => a.Name, StringComparer.Ordinal) + .ThenBy(a => a.UnsAreaId, StringComparer.Ordinal) + .Select(a => BuildArea(a, cluster.ClusterId, linesByArea, equipmentByLine)) + .ToList(); + + var node = new UnsNode + { + Kind = UnsNodeKind.Cluster, + Key = $"clu:{cluster.ClusterId}", + DisplayName = string.IsNullOrEmpty(cluster.Site) + ? cluster.Name + : $"{cluster.Site} ({cluster.Name})", + ClusterId = cluster.ClusterId, + EntityId = cluster.ClusterId, + HasLazyChildren = false, + }; + node.Children.AddRange(areaNodes); + node.ChildCount = node.Children.Count; + return node; + } + + private static UnsNode BuildArea( + AreaRow area, + string clusterId, + IReadOnlyDictionary> linesByArea, + IReadOnlyDictionary> equipmentByLine) + { + var lineNodes = (linesByArea.TryGetValue(area.UnsAreaId, out var areaLines) + ? areaLines + : Enumerable.Empty()) + .OrderBy(l => l.Name, StringComparer.Ordinal) + .ThenBy(l => l.UnsLineId, StringComparer.Ordinal) + .Select(l => BuildLine(l, clusterId, equipmentByLine)) + .ToList(); + + var node = new UnsNode + { + Kind = UnsNodeKind.Area, + Key = $"area:{area.UnsAreaId}", + DisplayName = area.Name, + ClusterId = clusterId, + EntityId = area.UnsAreaId, + HasLazyChildren = false, + }; + node.Children.AddRange(lineNodes); + node.ChildCount = node.Children.Count; + return node; + } + + private static UnsNode BuildLine( + LineRow line, + string clusterId, + IReadOnlyDictionary> equipmentByLine) + { + var equipmentNodes = (equipmentByLine.TryGetValue(line.UnsLineId, out var lineEquipment) + ? lineEquipment + : Enumerable.Empty()) + .OrderBy(e => e.Name, StringComparer.Ordinal) + .ThenBy(e => e.EquipmentId, StringComparer.Ordinal) + .Select(e => BuildEquipment(e, clusterId)) + .ToList(); + + var node = new UnsNode + { + Kind = UnsNodeKind.Line, + Key = $"line:{line.UnsLineId}", + DisplayName = line.Name, + ClusterId = clusterId, + EntityId = line.UnsLineId, + HasLazyChildren = false, + }; + node.Children.AddRange(equipmentNodes); + node.ChildCount = node.Children.Count; + return node; + } + + private static UnsNode BuildEquipment(EquipmentRow equipment, string clusterId) + { + var childCount = equipment.TagCount + equipment.VirtualTagCount; + return new UnsNode + { + Kind = UnsNodeKind.Equipment, + Key = $"eq:{equipment.EquipmentId}", + DisplayName = equipment.Name, + ClusterId = clusterId, + EntityId = equipment.EquipmentId, + ChildCount = childCount, + HasLazyChildren = childCount > 0, + }; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeAssemblyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeAssemblyTests.cs new file mode 100644 index 00000000..6df18be8 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeAssemblyTests.cs @@ -0,0 +1,166 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +public sealed class UnsTreeAssemblyTests +{ + [Fact] + public void Build_groups_clusters_under_enterprise() + { + var clusters = new[] + { + new ClusterRow("c1", "zb", "SiteA", "Alpha"), + new ClusterRow("c2", "zb", "SiteB", "Bravo"), + }; + + var tree = UnsTreeAssembly.Build( + clusters, + areas: System.Array.Empty(), + lines: System.Array.Empty(), + equipment: System.Array.Empty()); + + tree.Count.ShouldBe(1); + var ent = tree[0]; + ent.Kind.ShouldBe(UnsNodeKind.Enterprise); + ent.Key.ShouldBe("ent:zb"); + ent.DisplayName.ShouldBe("zb"); + ent.Children.Count.ShouldBe(2); + ent.Children.All(c => c.Kind == UnsNodeKind.Cluster).ShouldBeTrue(); + ent.Children.Select(c => c.Key).ShouldBe(new[] { "clu:c1", "clu:c2" }); + } + + [Fact] + public void Build_nests_area_line_equipment_under_owning_cluster() + { + var clusters = new[] { new ClusterRow("c1", "zb", "SiteA", "Alpha") }; + var areas = new[] { new AreaRow("a1", "c1", "AreaOne") }; + var lines = new[] { new LineRow("l1", "a1", "LineOne") }; + var equipment = new[] { new EquipmentRow("e1", "l1", "MC-1", "EquipOne", 0, 0) }; + + var tree = UnsTreeAssembly.Build(clusters, areas, lines, equipment); + + var cluster = tree.Single().Children.Single(); + cluster.Key.ShouldBe("clu:c1"); + + var area = cluster.Children.Single(); + area.Kind.ShouldBe(UnsNodeKind.Area); + area.Key.ShouldBe("area:a1"); + area.DisplayName.ShouldBe("AreaOne"); + area.ClusterId.ShouldBe("c1"); + area.EntityId.ShouldBe("a1"); + + var line = area.Children.Single(); + line.Kind.ShouldBe(UnsNodeKind.Line); + line.Key.ShouldBe("line:l1"); + line.DisplayName.ShouldBe("LineOne"); + line.ClusterId.ShouldBe("c1"); + line.EntityId.ShouldBe("l1"); + + var eq = line.Children.Single(); + eq.Kind.ShouldBe(UnsNodeKind.Equipment); + eq.Key.ShouldBe("eq:e1"); + eq.DisplayName.ShouldBe("EquipOne"); + eq.ClusterId.ShouldBe("c1"); + eq.EntityId.ShouldBe("e1"); + } + + [Fact] + public void Build_sets_equipment_child_count_and_lazy_flag() + { + var clusters = new[] { new ClusterRow("c1", "zb", "SiteA", "Alpha") }; + var areas = new[] { new AreaRow("a1", "c1", "AreaOne") }; + var lines = new[] { new LineRow("l1", "a1", "LineOne") }; + var equipment = new[] + { + new EquipmentRow("e1", "l1", "MC-1", "WithChildren", 2, 1), + new EquipmentRow("e2", "l1", "MC-2", "Empty", 0, 0), + }; + + var tree = UnsTreeAssembly.Build(clusters, areas, lines, equipment); + var line = tree.Single().Children.Single().Children.Single().Children.Single(); + + var withChildren = line.Children.Single(e => e.Key == "eq:e1"); + withChildren.ChildCount.ShouldBe(3); + withChildren.HasLazyChildren.ShouldBeTrue(); + + var empty = line.Children.Single(e => e.Key == "eq:e2"); + empty.ChildCount.ShouldBe(0); + empty.HasLazyChildren.ShouldBeFalse(); + } + + [Fact] + public void Build_includes_clusters_with_no_areas() + { + var clusters = new[] + { + new ClusterRow("c1", "zb", "SiteA", "Alpha"), + new ClusterRow("c2", "zb", "SiteB", "Bravo"), + }; + var areas = new[] { new AreaRow("a1", "c1", "AreaOne") }; + + var tree = UnsTreeAssembly.Build( + clusters, + areas, + lines: System.Array.Empty(), + equipment: System.Array.Empty()); + + var ent = tree.Single(); + ent.Children.Count.ShouldBe(2); + + var emptyCluster = ent.Children.Single(c => c.Key == "clu:c2"); + emptyCluster.Children.ShouldBeEmpty(); + + var populatedCluster = ent.Children.Single(c => c.Key == "clu:c1"); + populatedCluster.Children.Single().Key.ShouldBe("area:a1"); + } + + [Fact] + public void Build_orders_deterministically() + { + // Two enterprises out of order; clusters/areas/lines/equipment scrambled. + var clusters = new[] + { + new ClusterRow("cZ", "zeta", "SiteZ", "Zeta"), + new ClusterRow("c2", "alpha", "Site2", "Bravo"), + new ClusterRow("c1", "alpha", "Site1", "Alpha"), + }; + var areas = new[] + { + new AreaRow("a2", "c1", "Beta"), + new AreaRow("a1", "c1", "Alpha"), + }; + var lines = new[] + { + new LineRow("l2", "a1", "LineB"), + new LineRow("l1", "a1", "LineA"), + }; + var equipment = new[] + { + new EquipmentRow("e2", "l1", "MC-2", "EquipB", 0, 0), + new EquipmentRow("e1", "l1", "MC-1", "EquipA", 0, 0), + }; + + var tree = UnsTreeAssembly.Build(clusters, areas, lines, equipment); + + // Enterprises ordered by Enterprise (ordinal): "alpha" < "zeta". + tree.Select(e => e.Key).ShouldBe(new[] { "ent:alpha", "ent:zeta" }); + + // Clusters under "alpha" ordered by Name then ClusterId: "Alpha"(c1) < "Bravo"(c2). + var alphaEnt = tree.Single(e => e.Key == "ent:alpha"); + alphaEnt.Children.Select(c => c.Key).ShouldBe(new[] { "clu:c1", "clu:c2" }); + + // Areas ordered by Name then UnsAreaId: "Alpha"(a1) < "Beta"(a2). + var c1 = alphaEnt.Children.Single(c => c.Key == "clu:c1"); + c1.Children.Select(a => a.Key).ShouldBe(new[] { "area:a1", "area:a2" }); + + // Lines under a1 ordered by Name then UnsLineId: "LineA"(l1) < "LineB"(l2). + var a1 = c1.Children.Single(a => a.Key == "area:a1"); + a1.Children.Select(l => l.Key).ShouldBe(new[] { "line:l1", "line:l2" }); + + // Equipment under l1 ordered by Name then EquipmentId: "EquipA"(e1) < "EquipB"(e2). + var l1 = a1.Children.Single(l => l.Key == "line:l1"); + l1.Children.Select(eq => eq.Key).ShouldBe(new[] { "eq:e1", "eq:e2" }); + } +}