feat(uns): UnsNode VM + pure tree-assembly helper
This commit is contained in:
@@ -0,0 +1,242 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public enum UnsNodeKind
|
||||||
|
{
|
||||||
|
Enterprise,
|
||||||
|
Cluster,
|
||||||
|
Area,
|
||||||
|
Line,
|
||||||
|
Equipment,
|
||||||
|
Tag,
|
||||||
|
VirtualTag,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UnsNode
|
||||||
|
{
|
||||||
|
/// <summary>The kind of node — drives styling and entity linking.</summary>
|
||||||
|
public required UnsNodeKind Kind { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Stable per-node identifier, unique within the tree (e.g. <c>eq:{equipmentId}</c>).</summary>
|
||||||
|
public required string Key { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Human-readable label shown in the tree.</summary>
|
||||||
|
public required string DisplayName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Owning cluster id for Area/Line/Equipment (and the cluster itself); null for Enterprise.</summary>
|
||||||
|
public string? ClusterId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>This node's own logical entity id (UnsAreaId/UnsLineId/EquipmentId/TagId/VirtualTagId); null for Enterprise.</summary>
|
||||||
|
public string? EntityId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Badge count. For equipment this is tag + virtual-tag count; for container nodes it is the direct child count.</summary>
|
||||||
|
public int ChildCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True when this node has children that are loaded lazily (equipment with <see cref="ChildCount"/> > 0).</summary>
|
||||||
|
public bool HasLazyChildren { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Eagerly-materialised children. Empty for lazy-loaded equipment until expanded.</summary>
|
||||||
|
public List<UnsNode> Children { get; } = new();
|
||||||
|
|
||||||
|
// --- Runtime UI state (not persisted) ---
|
||||||
|
|
||||||
|
/// <summary>Whether the node is currently expanded in the UI.</summary>
|
||||||
|
public bool Expanded { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether the node's lazy children have been loaded.</summary>
|
||||||
|
public bool Loaded { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether a lazy-load is currently in flight for this node.</summary>
|
||||||
|
public bool Loading { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Last load error message, if any.</summary>
|
||||||
|
public string? Error { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Flat structural row describing a cluster and its enterprise/site placement.</summary>
|
||||||
|
public readonly record struct ClusterRow(string ClusterId, string Enterprise, string Site, string Name);
|
||||||
|
|
||||||
|
/// <summary>Flat structural row describing a UNS area and its owning cluster.</summary>
|
||||||
|
public readonly record struct AreaRow(string UnsAreaId, string ClusterId, string Name);
|
||||||
|
|
||||||
|
/// <summary>Flat structural row describing a UNS line and its owning area.</summary>
|
||||||
|
public readonly record struct LineRow(string UnsLineId, string UnsAreaId, string Name);
|
||||||
|
|
||||||
|
/// <summary>Flat structural row describing an equipment node, its owning line and its tag/virtual-tag counts.</summary>
|
||||||
|
public readonly record struct EquipmentRow(
|
||||||
|
string EquipmentId,
|
||||||
|
string UnsLineId,
|
||||||
|
string MachineCode,
|
||||||
|
string Name,
|
||||||
|
int TagCount,
|
||||||
|
int VirtualTagCount);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class UnsTreeAssembly
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<UnsNode> Build(
|
||||||
|
IReadOnlyList<ClusterRow> clusters,
|
||||||
|
IReadOnlyList<AreaRow> areas,
|
||||||
|
IReadOnlyList<LineRow> lines,
|
||||||
|
IReadOnlyList<EquipmentRow> 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<string, List<AreaRow>> areasByCluster,
|
||||||
|
IReadOnlyDictionary<string, List<LineRow>> linesByArea,
|
||||||
|
IReadOnlyDictionary<string, List<EquipmentRow>> equipmentByLine)
|
||||||
|
{
|
||||||
|
var areaNodes = (areasByCluster.TryGetValue(cluster.ClusterId, out var clusterAreas)
|
||||||
|
? clusterAreas
|
||||||
|
: Enumerable.Empty<AreaRow>())
|
||||||
|
.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<string, List<LineRow>> linesByArea,
|
||||||
|
IReadOnlyDictionary<string, List<EquipmentRow>> equipmentByLine)
|
||||||
|
{
|
||||||
|
var lineNodes = (linesByArea.TryGetValue(area.UnsAreaId, out var areaLines)
|
||||||
|
? areaLines
|
||||||
|
: Enumerable.Empty<LineRow>())
|
||||||
|
.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<string, List<EquipmentRow>> equipmentByLine)
|
||||||
|
{
|
||||||
|
var equipmentNodes = (equipmentByLine.TryGetValue(line.UnsLineId, out var lineEquipment)
|
||||||
|
? lineEquipment
|
||||||
|
: Enumerable.Empty<EquipmentRow>())
|
||||||
|
.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AreaRow>(),
|
||||||
|
lines: System.Array.Empty<LineRow>(),
|
||||||
|
equipment: System.Array.Empty<EquipmentRow>());
|
||||||
|
|
||||||
|
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<LineRow>(),
|
||||||
|
equipment: System.Array.Empty<EquipmentRow>());
|
||||||
|
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user