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:
Joseph Doherty
2026-06-17 15:01:02 -04:00
parent 5d07ac24cb
commit cc017aabfc
3 changed files with 354 additions and 0 deletions
@@ -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);
}
}