diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugTreeBuilder.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugTreeBuilder.cs new file mode 100644 index 00000000..e746d423 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugTreeBuilder.cs @@ -0,0 +1,113 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment; + +/// +/// Pure (no Blazor/DI) builder that turns a flat list of streamed attribute (and, +/// in DV-4, alarm) events into a collapsible composition forest of +/// . Path-qualified canonical names are split on +/// '.' to derive branch nodes; the terminal segment carries the payload. +/// +public static class DebugTreeBuilder +{ + private const char Separator = '.'; + + /// + /// Build the attribute composition forest. + /// + /// Latest attribute value events (one per attribute). + /// + /// Optional name-contains filter (case-insensitive, matched against + /// ). Null/empty/whitespace + /// keeps everything; matching leaves carry along their ancestor branches. + /// + public static IReadOnlyList BuildAttributeTree( + IEnumerable attributes, string? filter) + { + ArgumentNullException.ThrowIfNull(attributes); + + var hasFilter = !string.IsNullOrWhiteSpace(filter); + + // Roots keyed by the first path segment; branch nodes deduped by full prefix. + var roots = new List(); + var branchByKey = new Dictionary(StringComparer.Ordinal); + + foreach (var attr in attributes) + { + if (hasFilter && + attr.AttributeName.IndexOf(filter!, StringComparison.OrdinalIgnoreCase) < 0) + { + continue; + } + + var segments = attr.AttributeName.Split(Separator); + + // Walk/create branch nodes for every segment except the last; the last + // segment becomes the leaf carrying the attribute event. + List currentLevel = roots; + string prefix = string.Empty; + + for (var i = 0; i < segments.Length - 1; i++) + { + prefix = prefix.Length == 0 ? segments[i] : prefix + Separator + segments[i]; + + if (!branchByKey.TryGetValue(prefix, out var branch)) + { + branch = new DebugTreeNode { Key = prefix, Segment = segments[i] }; + branchByKey[prefix] = branch; + currentLevel.Add(branch); + } + + currentLevel = branch.Children; + } + + var leaf = new DebugTreeNode + { + Key = attr.AttributeName, + Segment = segments[^1], + Attribute = attr, + }; + currentLevel.Add(leaf); + } + + SortAndRollUp(roots); + return roots; + } + + /// + /// DV-4 will implement the alarm composition tree (computed leaves, native + /// condition groupings, configured-placeholder branches). Intentionally not + /// implemented in DV-3 — do not add alarm logic here. + /// + public static IReadOnlyList BuildAlarmTree( + IEnumerable alarms, string? filter) + => throw new NotImplementedException("BuildAlarmTree is implemented in DV-4."); + + /// + /// Recursively sort each node's children by + /// (ordinal) and roll up bad-quality: a node has bad quality when its own + /// attribute is off-Good or any descendant leaf is off-Good. Post-order so a + /// child's roll-up is settled before its parent reads it. + /// + private static void SortAndRollUp(List nodes) + { + nodes.Sort(static (a, b) => string.CompareOrdinal(a.Segment, b.Segment)); + + foreach (var node in nodes) + { + // Leaf's own quality (branches normally have no attribute). + var bad = node.Attribute is not null && !IsGood(node.Attribute.Quality); + + if (node.Children.Count > 0) + { + SortAndRollUp(node.Children); + bad = bad || node.Children.Any(static c => c.HasBadQuality); + } + + node.HasBadQuality = bad; + } + } + + private static bool IsGood(string quality) + => string.Equals(quality, "Good", StringComparison.Ordinal); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugTreeNode.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugTreeNode.cs new file mode 100644 index 00000000..157361ed --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugTreeNode.cs @@ -0,0 +1,35 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; // AlarmState + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment; + +/// +/// A node in a Debug View composition tree. Attribute (and, in DV-4, alarm) +/// names are path-qualified canonical names (e.g. Motor1.Compressor.Pump); +/// the tree is derived by splitting those names on '.'. A node is either a +/// branch (composition member, no payload, has children) or a leaf (one attribute +/// or one alarm). Consumed by the generic TreeView<TItem> component. +/// +public sealed class DebugTreeNode +{ + /// Full canonical path — stable TreeView key. + public required string Key { get; init; } + + /// Display label (the last path segment). + public required string Segment { get; init; } + + public List Children { get; } = new(); + + // Leaf payloads — exactly one is set on a leaf; both null on a pure branch. + public AttributeValueChanged? Attribute { get; init; } + public AlarmStateChanged? Alarm { get; init; } // computed leaf, native condition, or placeholder (DV-4) + + public bool IsNativeBinding { get; init; } // branch grouping native conditions (DV-4) + + // Roll-up (set by the builder for branch nodes). + public AlarmState WorstState { get; set; } = AlarmState.Normal; + public int ActiveCount { get; set; } + public bool HasBadQuality { get; set; } + + public bool HasChildren => Children.Count > 0; +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugTreeBuilderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugTreeBuilderTests.cs new file mode 100644 index 00000000..9ee4ce24 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugTreeBuilderTests.cs @@ -0,0 +1,206 @@ +using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; + +/// +/// Pure unit tests for — node +/// model derivation, branch dedupe, child sort, bad-quality roll-up, and the +/// name-contains filter. No bUnit / DI required; the builder is a pure function. +/// +public class DebugTreeBuilderTests +{ + private static AttributeValueChanged Attr(string name, object? value = null, string quality = "Good") + => new("Inst", name, name, value, quality, DateTimeOffset.UtcNow); + + [Fact] + public void RootLevelAttribute_BecomesSingleLeaf() + { + var tree = DebugTreeBuilder.BuildAttributeTree(new[] { Attr("Speed") }, null); + + var node = Assert.Single(tree); + Assert.Equal("Speed", node.Key); + Assert.Equal("Speed", node.Segment); + Assert.False(node.HasChildren); + Assert.NotNull(node.Attribute); + Assert.Equal("Speed", node.Attribute!.AttributeName); + } + + [Fact] + public void TwoSiblingLeaves_ShareSingleBranch_SortedBySegment() + { + // Provided out of order to prove children are sorted ordinally. + var tree = DebugTreeBuilder.BuildAttributeTree( + new[] { Attr("Motor1.Temp"), Attr("Motor1.Speed") }, null); + + var branch = Assert.Single(tree); + Assert.Equal("Motor1", branch.Key); + Assert.Equal("Motor1", branch.Segment); + Assert.True(branch.HasChildren); + Assert.Null(branch.Attribute); + + Assert.Equal(2, branch.Children.Count); + Assert.Equal("Motor1.Speed", branch.Children[0].Key); + Assert.Equal("Speed", branch.Children[0].Segment); + Assert.Equal("Motor1.Temp", branch.Children[1].Key); + Assert.Equal("Temp", branch.Children[1].Segment); + } + + [Fact] + public void DeepPath_NestsBranchesByAccumulatedPrefix() + { + var tree = DebugTreeBuilder.BuildAttributeTree( + new[] { Attr("Motor1.Compressor.Pump") }, null); + + var motor = Assert.Single(tree); + Assert.Equal("Motor1", motor.Key); + Assert.True(motor.HasChildren); + + var compressor = Assert.Single(motor.Children); + Assert.Equal("Motor1.Compressor", compressor.Key); + Assert.Equal("Compressor", compressor.Segment); + Assert.True(compressor.HasChildren); + Assert.Null(compressor.Attribute); + + var pump = Assert.Single(compressor.Children); + Assert.Equal("Motor1.Compressor.Pump", pump.Key); + Assert.Equal("Pump", pump.Segment); + Assert.False(pump.HasChildren); + Assert.NotNull(pump.Attribute); + } + + [Fact] + public void BranchDedupe_SharedPrefixAcrossDeepPaths() + { + var tree = DebugTreeBuilder.BuildAttributeTree( + new[] + { + Attr("Motor1.Compressor.Pump"), + Attr("Motor1.Compressor.Valve"), + Attr("Motor1.Speed"), + }, + null); + + var motor = Assert.Single(tree); + Assert.Equal("Motor1", motor.Key); + // Two direct children: "Compressor" (branch) and "Speed" (leaf), sorted. + Assert.Equal(2, motor.Children.Count); + Assert.Equal("Motor1.Compressor", motor.Children[0].Key); + Assert.Equal("Motor1.Speed", motor.Children[1].Key); + + var compressor = motor.Children[0]; + Assert.Equal(2, compressor.Children.Count); + Assert.Equal("Motor1.Compressor.Pump", compressor.Children[0].Key); + Assert.Equal("Motor1.Compressor.Valve", compressor.Children[1].Key); + } + + [Fact] + public void RollUp_BadQualityDescendant_MarksAllAncestorBranches() + { + var tree = DebugTreeBuilder.BuildAttributeTree( + new[] + { + Attr("Motor1.Compressor.Pump", quality: "Bad"), + Attr("Motor1.Speed", quality: "Good"), + }, + null); + + var motor = Assert.Single(tree); + Assert.True(motor.HasBadQuality); + + var compressor = motor.Children.Single(c => c.Key == "Motor1.Compressor"); + Assert.True(compressor.HasBadQuality); + } + + [Fact] + public void RollUp_AllGood_LeavesBranchHasBadQualityFalse() + { + var tree = DebugTreeBuilder.BuildAttributeTree( + new[] { Attr("Motor1.Speed"), Attr("Motor1.Temp") }, null); + + var motor = Assert.Single(tree); + Assert.False(motor.HasBadQuality); + } + + [Theory] + [InlineData("Uncertain")] + [InlineData("Bad")] + public void RollUp_NonGoodQuality_CountsAsBad(string quality) + { + var tree = DebugTreeBuilder.BuildAttributeTree( + new[] { Attr("Motor1.Speed", quality: quality) }, null); + + Assert.True(Assert.Single(tree).HasBadQuality); + } + + [Fact] + public void Filter_KeepsOnlyMatchingLeaves_AndTheirAncestors() + { + var tree = DebugTreeBuilder.BuildAttributeTree( + new[] + { + Attr("Motor1.Temp"), + Attr("Motor1.Speed"), + Attr("Motor2.Pressure"), + }, + "temp"); + + // Only Motor1.Temp matches → just the Motor1 branch with one Temp leaf. + var motor = Assert.Single(tree); + Assert.Equal("Motor1", motor.Key); + var leaf = Assert.Single(motor.Children); + Assert.Equal("Motor1.Temp", leaf.Key); + } + + [Fact] + public void Filter_IsCaseInsensitive() + { + var tree = DebugTreeBuilder.BuildAttributeTree( + new[] { Attr("Motor1.Temp"), Attr("Motor1.Speed") }, "TEMP"); + + var motor = Assert.Single(tree); + Assert.Equal("Motor1.Temp", Assert.Single(motor.Children).Key); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Filter_EmptyOrWhitespace_ReturnsFullForest(string? filter) + { + var tree = DebugTreeBuilder.BuildAttributeTree( + new[] { Attr("Motor1.Temp"), Attr("Motor2.Pressure") }, filter); + + Assert.Equal(2, tree.Count); + Assert.Equal("Motor1", tree[0].Key); + Assert.Equal("Motor2", tree[1].Key); + } + + [Fact] + public void Filter_NoMatches_ReturnsEmptyForest() + { + var tree = DebugTreeBuilder.BuildAttributeTree( + new[] { Attr("Motor1.Temp") }, "nonexistent"); + + Assert.Empty(tree); + } + + [Fact] + public void TopLevelForest_SortedBySegment() + { + var tree = DebugTreeBuilder.BuildAttributeTree( + new[] { Attr("Zeta"), Attr("Alpha"), Attr("Motor1.Speed") }, null); + + Assert.Equal(3, tree.Count); + Assert.Equal("Alpha", tree[0].Key); + Assert.Equal("Motor1", tree[1].Key); + Assert.Equal("Zeta", tree[2].Key); + } + + [Fact] + public void EmptyInput_ReturnsEmptyForest() + { + var tree = DebugTreeBuilder.BuildAttributeTree(Array.Empty(), null); + Assert.Empty(tree); + } +}