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);
+ }
}