From 5f387ef3e3a5954576a5006afb621de1b579c50a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 17 Jun 2026 15:12:57 -0400 Subject: [PATCH] feat(debugview): DV-4 implement BuildAlarmTree (computed leaves, native binding nodes, roll-up, filter) Computed alarms place as leaves at their path-qualified AlarmName; native conditions group under a deduped IsNativeBinding branch keyed by NativeSourceCanonicalName with condition children keyed canonical::sourceRef. Configured-placeholder events materialise a childless binding node. Alarm roll-up (WorstState/ActiveCount) excludes placeholders. Filter matches AlarmName/SourceReference/NativeSourceCanonicalName (OrdinalIgnoreCase) and retains ancestor + binding branches. 20 new TDD cases; 18 attribute cases stay green. No DebugTreeNode model changes. --- .../Pages/Deployment/DebugTreeBuilder.cs | 188 +++++++++- .../Deployment/DebugTreeBuilderTests.cs | 332 ++++++++++++++++++ 2 files changed, 516 insertions(+), 4 deletions(-) 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 index 42dc7ec8..d8bc15ae 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugTreeBuilder.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugTreeBuilder.cs @@ -1,4 +1,5 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment; @@ -82,13 +83,192 @@ public static class DebugTreeBuilder } /// - /// 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. + /// Build the alarm composition forest from a flat list of . /// + /// Latest alarm-state events (computed alarms, native conditions, placeholders). + /// + /// Optional name-contains filter (case-insensitive). An alarm is kept when the filter + /// is a substring of any of , + /// , or + /// . Null/empty/whitespace + /// keeps everything; kept items carry along their ancestor branches and, for native + /// conditions, their binding node. + /// + /// + /// Two flavours are placed differently: + /// + /// Computed (): + /// is a path-qualified canonical name; the alarm becomes a LEAF at that path, exactly + /// like an attribute. + /// Native (OPC UA / MxAccess): the condition belongs to a source BINDING + /// identified by — itself a + /// path-qualified canonical name placed as a branch with + /// set. Non-placeholder events become + /// condition children (keyed canonical::sourceReference); a placeholder event + /// only materialises the (childless) binding node so the page can render + /// "no active conditions". + /// + /// public static IReadOnlyList BuildAlarmTree( IEnumerable alarms, string? filter) - => throw new NotImplementedException("BuildAlarmTree is implemented in DV-4."); + { + ArgumentNullException.ThrowIfNull(alarms); + + var hasFilter = !string.IsNullOrWhiteSpace(filter); + + // Roots keyed by the first path segment; branch nodes (and native binding + // nodes) deduped by their full accumulated prefix / canonical name. + var roots = new List(); + var branchByKey = new Dictionary(StringComparer.Ordinal); + + foreach (var alarm in alarms) + { + if (hasFilter && !MatchesFilter(alarm, filter!)) + { + continue; + } + + if (alarm.Kind == AlarmKind.Computed) + { + AddComputedLeaf(alarm, roots, branchByKey); + } + else + { + AddNativeCondition(alarm, roots, branchByKey); + } + } + + SortAndRollUpAlarms(roots); + return roots.AsReadOnly(); + } + + /// True when the filter is a case-insensitive substring of any of the alarm's name fields. + private static bool MatchesFilter(AlarmStateChanged alarm, string filter) + => alarm.AlarmName.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0 + || alarm.SourceReference.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0 + || alarm.NativeSourceCanonicalName.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0; + + /// Place a computed alarm as a leaf at its path-qualified canonical name. + private static void AddComputedLeaf( + AlarmStateChanged alarm, + List roots, + Dictionary branchByKey) + { + var segments = alarm.AlarmName.Split(Separator); + var parent = WalkBranches(segments, segments.Length - 1, roots, branchByKey); + + parent.Add(new DebugTreeNode + { + Key = alarm.AlarmName, + Segment = segments[^1], + Alarm = alarm, + }); + } + + /// + /// Place a native condition under its source-binding branch node. A placeholder only + /// materialises the (childless) binding node; a real condition adds a child leaf keyed + /// canonical::sourceReference. + /// + private static void AddNativeCondition( + AlarmStateChanged alarm, + List roots, + Dictionary branchByKey) + { + var canonical = alarm.NativeSourceCanonicalName; + var segments = canonical.Split(Separator); + + // Walk/create the ancestor branches above the binding node, then get-or-create + // the binding node itself (deduped by canonical name, flagged IsNativeBinding). + var parent = WalkBranches(segments, segments.Length - 1, roots, branchByKey); + + if (!branchByKey.TryGetValue(canonical, out var binding)) + { + binding = new DebugTreeNode + { + Key = canonical, + Segment = segments[^1], + IsNativeBinding = true, + }; + branchByKey[canonical] = binding; + parent.Add(binding); + } + + // Placeholder: leave the binding node childless ("no active conditions"). + if (alarm.IsConfiguredPlaceholder) + { + return; + } + + binding.Children.Add(new DebugTreeNode + { + Key = canonical + "::" + alarm.SourceReference, + Segment = alarm.SourceReference, + Alarm = alarm, + }); + } + + /// + /// Walk (creating as needed) the branch nodes for the first + /// segments, deduping by accumulated prefix, and return the child list at that depth. + /// + private static List WalkBranches( + string[] segments, + int count, + List roots, + Dictionary branchByKey) + { + var currentLevel = roots; + var prefix = string.Empty; + + for (var i = 0; i < count; 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; + } + + return currentLevel; + } + + /// + /// Recursively sort each node's children by + /// (ordinal) and roll up alarm state post-order: + /// is when any descendant alarm leaf/condition is Active + /// (placeholders excluded), and tallies those + /// active descendants. A native binding node rolls up over its condition children the + /// same way. (Bad-quality is attribute-only and left default for alarm nodes.) + /// + private static void SortAndRollUpAlarms(List nodes) + { + nodes.Sort(static (a, b) => string.CompareOrdinal(a.Segment, b.Segment)); + + foreach (var node in nodes) + { + // A leaf's own contribution: Active and not a configured placeholder. + var ownActive = IsActiveAlarm(node); + var activeCount = ownActive ? 1 : 0; + + if (node.Children.Count > 0) + { + SortAndRollUpAlarms(node.Children); + activeCount += node.Children.Sum(static c => c.ActiveCount); + } + + node.ActiveCount = activeCount; + node.WorstState = activeCount > 0 ? AlarmState.Active : AlarmState.Normal; + } + } + + private static bool IsActiveAlarm(DebugTreeNode node) + => node.Alarm is { State: AlarmState.Active, IsConfiguredPlaceholder: false }; /// /// Recursively sort each node's children by diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugTreeBuilderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugTreeBuilderTests.cs index 45aa2705..ffac838f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugTreeBuilderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugTreeBuilderTests.cs @@ -1,5 +1,6 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; @@ -232,4 +233,335 @@ public class DebugTreeBuilderTests Assert.Equal("Motor1.Compressor", compressor.Key); Assert.Equal("Motor1.Compressor.Pump", Assert.Single(compressor.Children).Key); } + + // --------------------------------------------------------------------- + // DV-4: BuildAlarmTree — computed leaves, native condition groupings, + // configured-placeholder binding nodes, roll-up, filter. + // --------------------------------------------------------------------- + + /// Computed alarm: AlarmName is the path-qualified canonical leaf path. + private static AlarmStateChanged Computed(string alarmName, AlarmState state = AlarmState.Active) + => new("Inst", alarmName, state, Priority: 500, DateTimeOffset.UtcNow); + + /// + /// Native condition (or placeholder) under a source binding identified by + /// . + /// + private static AlarmStateChanged Native( + string bindingCanonical, + string sourceReference, + AlarmState state = AlarmState.Active, + bool placeholder = false, + AlarmKind kind = AlarmKind.NativeOpcUa) + => new("Inst", sourceReference, state, Priority: 500, DateTimeOffset.UtcNow) + { + Kind = kind, + SourceReference = sourceReference, + NativeSourceCanonicalName = bindingCanonical, + IsConfiguredPlaceholder = placeholder, + }; + + [Fact] + public void Computed_PathQualifiedName_BecomesLeafUnderBranch() + { + var tree = DebugTreeBuilder.BuildAlarmTree( + new[] { Computed("Motor1.HighTemp") }, null); + + var motor = Assert.Single(tree); + Assert.Equal("Motor1", motor.Key); + Assert.Equal("Motor1", motor.Segment); + Assert.True(motor.HasChildren); + Assert.False(motor.IsNativeBinding); + Assert.Null(motor.Alarm); + + var leaf = Assert.Single(motor.Children); + Assert.Equal("Motor1.HighTemp", leaf.Key); + Assert.Equal("HighTemp", leaf.Segment); + Assert.False(leaf.HasChildren); + Assert.NotNull(leaf.Alarm); + Assert.Equal("Motor1.HighTemp", leaf.Alarm!.AlarmName); + } + + [Fact] + public void Computed_RootLevelName_BecomesSingleLeaf() + { + var tree = DebugTreeBuilder.BuildAlarmTree(new[] { Computed("Overheat") }, null); + + var node = Assert.Single(tree); + Assert.Equal("Overheat", node.Key); + Assert.Equal("Overheat", node.Segment); + Assert.False(node.HasChildren); + Assert.NotNull(node.Alarm); + } + + [Fact] + public void Native_Condition_NestsUnderBindingNode_WithSourceReferenceSegment() + { + var tree = DebugTreeBuilder.BuildAlarmTree( + new[] { Native("Tank1.Levels", "Tank1.Level.HiHi") }, null); + + var tank = Assert.Single(tree); + Assert.Equal("Tank1", tank.Key); + Assert.False(tank.IsNativeBinding); + + var binding = Assert.Single(tank.Children); + Assert.Equal("Tank1.Levels", binding.Key); + Assert.Equal("Levels", binding.Segment); + Assert.True(binding.IsNativeBinding); + Assert.Null(binding.Alarm); + + var condition = Assert.Single(binding.Children); + Assert.Equal("Tank1.Levels::Tank1.Level.HiHi", condition.Key); + Assert.Equal("Tank1.Level.HiHi", condition.Segment); + Assert.False(condition.HasChildren); + Assert.NotNull(condition.Alarm); + Assert.Equal("Tank1.Level.HiHi", condition.Alarm!.SourceReference); + } + + [Fact] + public void Native_TwoConditions_ShareSameBindingNode() + { + var tree = DebugTreeBuilder.BuildAlarmTree( + new[] + { + Native("Tank1.Levels", "Tank1.Level.HiHi"), + Native("Tank1.Levels", "Tank1.Level.LoLo"), + }, + null); + + var tank = Assert.Single(tree); + var binding = Assert.Single(tank.Children); + Assert.Equal("Tank1.Levels", binding.Key); + Assert.True(binding.IsNativeBinding); + + // ONE binding node carrying two condition children (sorted by SourceReference). + Assert.Equal(2, binding.Children.Count); + Assert.Equal("Tank1.Levels::Tank1.Level.HiHi", binding.Children[0].Key); + Assert.Equal("Tank1.Levels::Tank1.Level.LoLo", binding.Children[1].Key); + } + + [Fact] + public void Native_Placeholder_YieldsChildlessBindingNode() + { + var tree = DebugTreeBuilder.BuildAlarmTree( + new[] { Native("Tank1.Levels", "ignored", placeholder: true) }, null); + + var tank = Assert.Single(tree); + var binding = Assert.Single(tank.Children); + Assert.Equal("Tank1.Levels", binding.Key); + Assert.True(binding.IsNativeBinding); + Assert.False(binding.HasChildren); + Assert.Empty(binding.Children); + } + + [Fact] + public void Native_PlaceholderAndRealConditions_RealConditionsWin() + { + // DV-2 shouldn't emit both, but be safe: placeholder adds nothing. + var tree = DebugTreeBuilder.BuildAlarmTree( + new[] + { + Native("Tank1.Levels", "ignored", placeholder: true), + Native("Tank1.Levels", "Tank1.Level.HiHi"), + }, + null); + + var tank = Assert.Single(tree); + var binding = Assert.Single(tank.Children); + Assert.True(binding.IsNativeBinding); + var condition = Assert.Single(binding.Children); + Assert.Equal("Tank1.Levels::Tank1.Level.HiHi", condition.Key); + } + + [Fact] + public void RollUp_ActiveDescendant_SetsWorstStateActiveAndCounts() + { + var tree = DebugTreeBuilder.BuildAlarmTree( + new[] + { + Native("Tank1.Levels", "Tank1.Level.HiHi", AlarmState.Active), + Native("Tank1.Levels", "Tank1.Level.LoLo", AlarmState.Normal), + Computed("Tank1.Overflow", AlarmState.Active), + }, + null); + + var tank = Assert.Single(tree); + Assert.Equal(AlarmState.Active, tank.WorstState); + Assert.Equal(2, tank.ActiveCount); // one native HiHi + one computed Overflow + + var binding = tank.Children.Single(c => c.IsNativeBinding); + Assert.Equal(AlarmState.Active, binding.WorstState); + Assert.Equal(1, binding.ActiveCount); + } + + [Fact] + public void RollUp_AllNormal_WorstStateNormalAndZeroCount() + { + var tree = DebugTreeBuilder.BuildAlarmTree( + new[] { Computed("Tank1.Overflow", AlarmState.Normal) }, null); + + var tank = Assert.Single(tree); + Assert.Equal(AlarmState.Normal, tank.WorstState); + Assert.Equal(0, tank.ActiveCount); + } + + [Fact] + public void RollUp_Placeholder_NeverCountsAsActive() + { + // A placeholder must not contribute to ActiveCount or WorstState even + // though its underlying State is Active (the factory default). + var tree = DebugTreeBuilder.BuildAlarmTree( + new[] { Native("Tank1.Levels", "ignored", AlarmState.Active, placeholder: true) }, + null); + + var tank = Assert.Single(tree); + Assert.Equal(AlarmState.Normal, tank.WorstState); + Assert.Equal(0, tank.ActiveCount); + + var binding = Assert.Single(tank.Children); + Assert.Equal(AlarmState.Normal, binding.WorstState); + Assert.Equal(0, binding.ActiveCount); + } + + [Fact] + public void Filter_OnSourceReference_KeepsConditionUnderBindingAndAncestors() + { + var tree = DebugTreeBuilder.BuildAlarmTree( + new[] + { + Native("Tank1.Levels", "Tank1.Level.HiHi"), + Native("Tank1.Levels", "Tank1.Level.LoLo"), + }, + "HiHi"); + + var tank = Assert.Single(tree); + Assert.Equal("Tank1", tank.Key); + var binding = Assert.Single(tank.Children); + Assert.Equal("Tank1.Levels", binding.Key); + Assert.True(binding.IsNativeBinding); + var condition = Assert.Single(binding.Children); + Assert.Equal("Tank1.Levels::Tank1.Level.HiHi", condition.Key); + } + + [Fact] + public void Filter_OnBindingCanonicalName_KeepsBindingNode() + { + var tree = DebugTreeBuilder.BuildAlarmTree( + new[] + { + Native("Tank1.Levels", "Tank1.Level.HiHi"), + Computed("Motor1.HighTemp"), + }, + "Tank1.Levels"); + + // Only the native binding matches (via NativeSourceCanonicalName). + var tank = Assert.Single(tree); + Assert.Equal("Tank1", tank.Key); + var binding = Assert.Single(tank.Children); + Assert.Equal("Tank1.Levels", binding.Key); + Assert.True(binding.IsNativeBinding); + Assert.Single(binding.Children); + } + + [Fact] + public void Filter_OnBindingName_MatchingPlaceholder_KeepsBindingNode() + { + var tree = DebugTreeBuilder.BuildAlarmTree( + new[] { Native("Tank1.Levels", "ignored", placeholder: true) }, + "Levels"); + + var tank = Assert.Single(tree); + var binding = Assert.Single(tank.Children); + Assert.Equal("Tank1.Levels", binding.Key); + Assert.True(binding.IsNativeBinding); + Assert.False(binding.HasChildren); + } + + [Fact] + public void Filter_OnComputedAlarmName_IsCaseInsensitive() + { + var tree = DebugTreeBuilder.BuildAlarmTree( + new[] { Computed("Motor1.HighTemp"), Computed("Motor1.LowPressure") }, + "hightemp"); + + var motor = Assert.Single(tree); + Assert.Equal("Motor1.HighTemp", Assert.Single(motor.Children).Key); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Filter_EmptyOrWhitespace_ReturnsFullAlarmForest(string? filter) + { + var tree = DebugTreeBuilder.BuildAlarmTree( + new[] { Computed("Motor1.HighTemp"), Native("Tank1.Levels", "Tank1.Level.HiHi") }, + filter); + + Assert.Equal(2, tree.Count); + Assert.Equal("Motor1", tree[0].Key); + Assert.Equal("Tank1", tree[1].Key); + } + + [Fact] + public void Filter_NoMatches_ReturnsEmptyAlarmForest() + { + var tree = DebugTreeBuilder.BuildAlarmTree( + new[] { Computed("Motor1.HighTemp") }, "nonexistent"); + + Assert.Empty(tree); + } + + [Fact] + public void MixedComputedAndNative_ProduceBothSubtrees() + { + var tree = DebugTreeBuilder.BuildAlarmTree( + new[] + { + Computed("Motor1.HighTemp"), + Native("Tank1.Levels", "Tank1.Level.HiHi", kind: AlarmKind.NativeMxAccess), + }, + null); + + Assert.Equal(2, tree.Count); + + var motor = tree.Single(n => n.Key == "Motor1"); + var computedLeaf = Assert.Single(motor.Children); + Assert.Equal("Motor1.HighTemp", computedLeaf.Key); + Assert.False(computedLeaf.HasChildren); + + var tank = tree.Single(n => n.Key == "Tank1"); + var binding = Assert.Single(tank.Children); + Assert.True(binding.IsNativeBinding); + Assert.Equal("Tank1.Levels::Tank1.Level.HiHi", Assert.Single(binding.Children).Key); + } + + [Fact] + public void EmptyAlarmInput_ReturnsEmptyForest() + { + var tree = DebugTreeBuilder.BuildAlarmTree(Array.Empty(), null); + Assert.Empty(tree); + } + + [Fact] + public void Native_ComputedAndNativeSharePrefix_DistinctBindingAndLeaf() + { + // Tank1 has a computed alarm AND a native binding — both under one Tank1 branch. + var tree = DebugTreeBuilder.BuildAlarmTree( + new[] + { + Computed("Tank1.Overflow"), + Native("Tank1.Levels", "Tank1.Level.HiHi"), + }, + null); + + var tank = Assert.Single(tree); + Assert.Equal(2, tank.Children.Count); + + var binding = tank.Children.Single(c => c.IsNativeBinding); + Assert.Equal("Tank1.Levels", binding.Key); + var computed = tank.Children.Single(c => !c.IsNativeBinding); + Assert.Equal("Tank1.Overflow", computed.Key); + Assert.NotNull(computed.Alarm); + } }