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:
+184
-4
@@ -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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="AlarmStateChanged"/>.
|
||||
/// </summary>
|
||||
/// <param name="alarms">Latest alarm-state events (computed alarms, native conditions, placeholders).</param>
|
||||
/// <param name="filter">
|
||||
/// Optional name-contains filter (case-insensitive). An alarm is kept when the filter
|
||||
/// is a substring of any of <see cref="AlarmStateChanged.AlarmName"/>,
|
||||
/// <see cref="AlarmStateChanged.SourceReference"/>, or
|
||||
/// <see cref="AlarmStateChanged.NativeSourceCanonicalName"/>. Null/empty/whitespace
|
||||
/// keeps everything; kept items carry along their ancestor branches and, for native
|
||||
/// conditions, their binding node.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// Two flavours are placed differently:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Computed</b> (<see cref="AlarmKind.Computed"/>): <see cref="AlarmStateChanged.AlarmName"/>
|
||||
/// is a path-qualified canonical name; the alarm becomes a LEAF at that path, exactly
|
||||
/// like an attribute.</item>
|
||||
/// <item><b>Native</b> (OPC UA / MxAccess): the condition belongs to a source BINDING
|
||||
/// identified by <see cref="AlarmStateChanged.NativeSourceCanonicalName"/> — itself a
|
||||
/// path-qualified canonical name placed as a branch with
|
||||
/// <see cref="DebugTreeNode.IsNativeBinding"/> set. Non-placeholder events become
|
||||
/// condition children (keyed <c>canonical::sourceReference</c>); a placeholder event
|
||||
/// only materialises the (childless) binding node so the page can render
|
||||
/// "no active conditions".</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<DebugTreeNode> BuildAlarmTree(
|
||||
IEnumerable<AlarmStateChanged> 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<DebugTreeNode>();
|
||||
var branchByKey = new Dictionary<string, DebugTreeNode>(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();
|
||||
}
|
||||
|
||||
/// <summary>True when the filter is a case-insensitive substring of any of the alarm's name fields.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Place a computed alarm as a leaf at its path-qualified canonical name.</summary>
|
||||
private static void AddComputedLeaf(
|
||||
AlarmStateChanged alarm,
|
||||
List<DebugTreeNode> roots,
|
||||
Dictionary<string, DebugTreeNode> 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,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>canonical::sourceReference</c>.
|
||||
/// </summary>
|
||||
private static void AddNativeCondition(
|
||||
AlarmStateChanged alarm,
|
||||
List<DebugTreeNode> roots,
|
||||
Dictionary<string, DebugTreeNode> 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,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walk (creating as needed) the branch nodes for the first <paramref name="count"/>
|
||||
/// segments, deduping by accumulated prefix, and return the child list at that depth.
|
||||
/// </summary>
|
||||
private static List<DebugTreeNode> WalkBranches(
|
||||
string[] segments,
|
||||
int count,
|
||||
List<DebugTreeNode> roots,
|
||||
Dictionary<string, DebugTreeNode> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively sort each node's children by <see cref="DebugTreeNode.Segment"/>
|
||||
/// (ordinal) and roll up alarm state post-order: <see cref="DebugTreeNode.WorstState"/>
|
||||
/// is <see cref="AlarmState.Active"/> when any descendant alarm leaf/condition is Active
|
||||
/// (placeholders excluded), and <see cref="DebugTreeNode.ActiveCount"/> 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.)
|
||||
/// </summary>
|
||||
private static void SortAndRollUpAlarms(List<DebugTreeNode> 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 };
|
||||
|
||||
/// <summary>
|
||||
/// Recursively sort each node's children by <see cref="DebugTreeNode.Segment"/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user