feat(debugview): DV-3 DebugTreeNode model + attribute tree builder
Pure path-split composition forest from streamed AttributeValueChanged: branch dedupe by accumulated prefix, ordinal child sort, post-order bad-quality roll-up, case-insensitive name-contains filter (keeps ancestors). BuildAlarmTree left as a NotImplementedException stub for DV-4. 16 unit tests cover structure + roll-up + filter.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="DebugTreeNode"/>. Path-qualified canonical names are split on
|
||||
/// <c>'.'</c> to derive branch nodes; the terminal segment carries the payload.
|
||||
/// </summary>
|
||||
public static class DebugTreeBuilder
|
||||
{
|
||||
private const char Separator = '.';
|
||||
|
||||
/// <summary>
|
||||
/// Build the attribute composition forest.
|
||||
/// </summary>
|
||||
/// <param name="attributes">Latest attribute value events (one per attribute).</param>
|
||||
/// <param name="filter">
|
||||
/// Optional name-contains filter (case-insensitive, matched against
|
||||
/// <see cref="AttributeValueChanged.AttributeName"/>). Null/empty/whitespace
|
||||
/// keeps everything; matching leaves carry along their ancestor branches.
|
||||
/// </param>
|
||||
public static IReadOnlyList<DebugTreeNode> BuildAttributeTree(
|
||||
IEnumerable<AttributeValueChanged> 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<DebugTreeNode>();
|
||||
var branchByKey = new Dictionary<string, DebugTreeNode>(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<DebugTreeNode> 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;
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<DebugTreeNode> BuildAlarmTree(
|
||||
IEnumerable<AlarmStateChanged> alarms, string? filter)
|
||||
=> throw new NotImplementedException("BuildAlarmTree is implemented in DV-4.");
|
||||
|
||||
/// <summary>
|
||||
/// Recursively sort each node's children by <see cref="DebugTreeNode.Segment"/>
|
||||
/// (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.
|
||||
/// </summary>
|
||||
private static void SortAndRollUp(List<DebugTreeNode> 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// A node in a Debug View composition tree. Attribute (and, in DV-4, alarm)
|
||||
/// names are path-qualified canonical names (e.g. <c>Motor1.Compressor.Pump</c>);
|
||||
/// the tree is derived by splitting those names on <c>'.'</c>. A node is either a
|
||||
/// branch (composition member, no payload, has children) or a leaf (one attribute
|
||||
/// or one alarm). Consumed by the generic <c>TreeView<TItem></c> component.
|
||||
/// </summary>
|
||||
public sealed class DebugTreeNode
|
||||
{
|
||||
/// <summary>Full canonical path — stable TreeView key.</summary>
|
||||
public required string Key { get; init; }
|
||||
|
||||
/// <summary>Display label (the last path segment).</summary>
|
||||
public required string Segment { get; init; }
|
||||
|
||||
public List<DebugTreeNode> 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Pure unit tests for <see cref="DebugTreeBuilder.BuildAttributeTree"/> — 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.
|
||||
/// </summary>
|
||||
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<AttributeValueChanged>(), null);
|
||||
Assert.Empty(tree);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user