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.
This commit is contained in:
Joseph Doherty
2026-06-17 15:12:57 -04:00
parent 69b83379d5
commit 5f387ef3e3
2 changed files with 516 additions and 4 deletions
@@ -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.
// ---------------------------------------------------------------------
/// <summary>Computed alarm: <c>AlarmName</c> is the path-qualified canonical leaf path.</summary>
private static AlarmStateChanged Computed(string alarmName, AlarmState state = AlarmState.Active)
=> new("Inst", alarmName, state, Priority: 500, DateTimeOffset.UtcNow);
/// <summary>
/// Native condition (or placeholder) under a source binding identified by
/// <paramref name="bindingCanonical"/>.
/// </summary>
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<AlarmStateChanged>(), 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);
}
}