feat(centralui): DV-5 — Debug View tabbed composition trees (Attributes/Alarms)
Replace the two flat capped tables with a Bootstrap nav-tabs layout, each tab hosting a TreeView<DebugTreeNode> built from the live latest-per-name dictionaries via DebugTreeBuilder. Drop the MaxRows cap, auto-scroll locks, and Clear buttons (change-feed affordances that don't fit a current-status tree); HandleStreamEvent now does a plain dictionary upsert. Per-tab filters ExpandAll on change so matches stay visible. Branch nodes surface roll-up badges (active-count for alarms, bad-quality for attributes); native binding nodes show active-count or 'no active conditions'. All existing badge helpers and ValueFormatter reused. Marshalling/dispose/reconnect contract preserved (SafeInvokeAsync/_disposed/Dispose unchanged; FilteredAttributeValues kept as the render-thread dict reader the CentralUI-021 race test exercises). Rework DebugViewAlarmTableTests for the tabbed-tree DOM: tab presence+default, computed alarm grouped under its Motor1 branch with the active roll-up badge, and a native condition nested under its source-binding node with the enriched kind/severity/Unacked/Shelved badge set.
This commit is contained in:
+127
-19
@@ -20,9 +20,13 @@ using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploymen
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// Task 23: the DebugView alarm table surfaces enriched native alarm state —
|
||||
/// kind, severity, source reference, and a composite condition badge set
|
||||
/// (Unacked / Shelved / Suppressed).
|
||||
/// DV-5: the Debug View renders a tabbed (Attributes / Alarms) layout, each tab
|
||||
/// hosting a composition <c>TreeView</c>. Computed alarms and attributes nest as
|
||||
/// leaves under their composition-member branches; native conditions nest under
|
||||
/// their source-binding node. Branch nodes surface a roll-up (active-count /
|
||||
/// bad-quality) badge. These tests assert the tree structure + roll-up, not just
|
||||
/// that markup exists, and that the enriched native-alarm badge set
|
||||
/// (kind / severity / Unacked / Shelved) still renders inside the tree leaf.
|
||||
/// </summary>
|
||||
public class DebugViewAlarmTableTests : BunitContext
|
||||
{
|
||||
@@ -69,35 +73,139 @@ public class DebugViewAlarmTableTests : BunitContext
|
||||
(Dictionary<string, AlarmStateChanged>)typeof(DebugViewPage)
|
||||
.GetField("_alarmStates", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(c)!;
|
||||
|
||||
private static Dictionary<string, AttributeValueChanged> AttributeValues(DebugViewPage c) =>
|
||||
(Dictionary<string, AttributeValueChanged>)typeof(DebugViewPage)
|
||||
.GetField("_attributeValues", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(c)!;
|
||||
|
||||
/// <summary>
|
||||
/// Mark the page connected with the given snapshot and seed the latest-per-name
|
||||
/// dictionaries, then re-render. Mirrors what <c>Connect()</c> does from a real
|
||||
/// snapshot without driving the gRPC stream.
|
||||
/// </summary>
|
||||
private static void Connect(
|
||||
IRenderedComponent<DebugViewPage> cut,
|
||||
IReadOnlyList<AttributeValueChanged> attributes,
|
||||
IReadOnlyList<AlarmStateChanged> alarms)
|
||||
{
|
||||
cut.InvokeAsync(() =>
|
||||
{
|
||||
SetField(cut.Instance, "_connected", true);
|
||||
SetField(cut.Instance, "_snapshot",
|
||||
new DebugViewSnapshot("inst", attributes.ToList(), alarms.ToList(), DateTimeOffset.UtcNow));
|
||||
foreach (var a in attributes) AttributeValues(cut.Instance)[a.AttributeName] = a;
|
||||
foreach (var a in alarms) AlarmStates(cut.Instance)[a.AlarmName] = a;
|
||||
});
|
||||
cut.Render();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmTable_RendersNativeAlarm_WithSeverityKindAndShelvedBadge()
|
||||
public void BothTabsPresent_AttributesTabIsDefault_AndShowsAttributeTree()
|
||||
{
|
||||
var cut = RenderPage();
|
||||
|
||||
var speed = new AttributeValueChanged(
|
||||
"inst", "Motor1.Speed", "Motor1.Speed", 1450, "Good", DateTimeOffset.UtcNow);
|
||||
var alarm = new AlarmStateChanged("inst", "Motor1.HighTemp", AlarmState.Active, 700, DateTimeOffset.UtcNow);
|
||||
|
||||
Connect(cut, new[] { speed }, new[] { alarm });
|
||||
|
||||
// Both tab hooks render.
|
||||
var attrTab = cut.Find("[data-test='debug-tab-attributes']");
|
||||
var alarmTab = cut.Find("[data-test='debug-tab-alarms']");
|
||||
Assert.Contains("active", attrTab.GetAttribute("class")!); // Attributes is the default active tab
|
||||
Assert.DoesNotContain("active", alarmTab.GetAttribute("class")!);
|
||||
|
||||
// The Attributes pane is visible (not d-none); the Alarms pane is hidden.
|
||||
var attrPane = cut.Find("[data-test='debug-pane-attributes']");
|
||||
var alarmPane = cut.Find("[data-test='debug-pane-alarms']");
|
||||
Assert.DoesNotContain("d-none", attrPane.GetAttribute("class")!);
|
||||
Assert.Contains("d-none", alarmPane.GetAttribute("class")!);
|
||||
|
||||
// The attribute tree shows the Motor1 branch and the Speed leaf nested under it.
|
||||
var attrItems = attrPane.QuerySelectorAll("li[role='treeitem']");
|
||||
Assert.Contains(attrItems, li => li.TextContent.Contains("Motor1"));
|
||||
Assert.Contains(attrItems, li => li.TextContent.Contains("Speed") && li.TextContent.Contains("1450"));
|
||||
|
||||
// The leaf for Speed must be nested inside the Motor1 branch's group, not a root.
|
||||
var motor1 = attrItems.Single(li =>
|
||||
li.QuerySelector(".tv-content")!.TextContent.Trim().StartsWith("Motor1"));
|
||||
Assert.NotNull(motor1.QuerySelector("ul[role='group'] li[role='treeitem']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmsTab_GroupsComputedAlarmUnderBranch_WithActiveRollUpBadge()
|
||||
{
|
||||
var cut = RenderPage();
|
||||
|
||||
// Two computed alarms under the same Motor1 module — one active, one normal.
|
||||
var high = new AlarmStateChanged("inst", "Motor1.HighTemp", AlarmState.Active, 700, DateTimeOffset.UtcNow);
|
||||
var ok = new AlarmStateChanged("inst", "Motor1.Overload", AlarmState.Normal, 200, DateTimeOffset.UtcNow);
|
||||
|
||||
Connect(cut, Array.Empty<AttributeValueChanged>(), new[] { high, ok });
|
||||
|
||||
// Switch to the Alarms tab.
|
||||
cut.Find("[data-test='debug-tab-alarms']").Click();
|
||||
|
||||
var alarmPane = cut.Find("[data-test='debug-pane-alarms']");
|
||||
Assert.DoesNotContain("d-none", alarmPane.GetAttribute("class")!);
|
||||
|
||||
var items = alarmPane.QuerySelectorAll("li[role='treeitem']");
|
||||
|
||||
// Motor1 branch carries the roll-up "1 active" danger badge.
|
||||
var motor1 = items.Single(li =>
|
||||
li.QuerySelector(".tv-content")!.TextContent.Trim().StartsWith("Motor1"));
|
||||
var rollup = motor1.QuerySelector(".tv-content .badge.bg-danger");
|
||||
Assert.NotNull(rollup);
|
||||
Assert.Contains("1 active", rollup!.TextContent);
|
||||
|
||||
// The active alarm leaf nests under Motor1 and shows the Active state badge.
|
||||
var group = motor1.QuerySelector("ul[role='group']");
|
||||
Assert.NotNull(group);
|
||||
var leaves = group!.QuerySelectorAll("li[role='treeitem']");
|
||||
Assert.Contains(leaves, li => li.TextContent.Contains("HighTemp"));
|
||||
Assert.Contains(leaves, li => li.TextContent.Contains("Overload"));
|
||||
var highTemp = leaves.Single(li => li.TextContent.Contains("HighTemp"));
|
||||
Assert.NotNull(highTemp.QuerySelector(".badge.bg-danger")); // Active state badge
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmsTab_RendersNativeCondition_WithEnrichedBadgesUnderBindingNode()
|
||||
{
|
||||
var cut = RenderPage();
|
||||
|
||||
// A native OPC UA condition belonging to the Motor1.MotorAlarms binding.
|
||||
var native = new AlarmStateChanged("inst", "T01.Hi", AlarmState.Active, 800, DateTimeOffset.UtcNow)
|
||||
{
|
||||
Kind = AlarmKind.NativeOpcUa,
|
||||
NativeSourceCanonicalName = "Motor1.MotorAlarms",
|
||||
SourceReference = "ns=2;s=Tank01.Level.HiHi",
|
||||
Condition = new AlarmConditionState(
|
||||
Active: true, Acknowledged: false, Confirmed: null,
|
||||
Shelve: AlarmShelveState.OneShotShelved, Suppressed: false, Severity: 800)
|
||||
};
|
||||
|
||||
cut.InvokeAsync(() =>
|
||||
{
|
||||
SetField(cut.Instance, "_connected", true);
|
||||
SetField(cut.Instance, "_snapshot",
|
||||
new DebugViewSnapshot("inst", new List<AttributeValueChanged>(),
|
||||
new List<AlarmStateChanged> { native }, DateTimeOffset.UtcNow));
|
||||
AlarmStates(cut.Instance)["T01.Hi"] = native;
|
||||
});
|
||||
cut.Render();
|
||||
Connect(cut, Array.Empty<AttributeValueChanged>(), new[] { native });
|
||||
|
||||
var markup = cut.Markup;
|
||||
Assert.Contains("800", markup); // severity surfaced
|
||||
Assert.Contains("Shelved", markup); // composite condition badge
|
||||
Assert.Contains("OPC UA", markup); // native kind badge
|
||||
Assert.Contains("ns=2;s=Tank01.Level.HiHi", markup); // source reference
|
||||
Assert.Contains("Unacked", markup); // active + unacknowledged
|
||||
cut.Find("[data-test='debug-tab-alarms']").Click();
|
||||
|
||||
var alarmPane = cut.Find("[data-test='debug-pane-alarms']");
|
||||
var items = alarmPane.QuerySelectorAll("li[role='treeitem']");
|
||||
|
||||
// The source-binding node renders with its roll-up active badge.
|
||||
var binding = items.Single(li =>
|
||||
li.QuerySelector(".tv-content")!.TextContent.Trim().StartsWith("MotorAlarms"));
|
||||
var bindingBadge = binding.QuerySelector(".tv-content .badge");
|
||||
Assert.NotNull(bindingBadge);
|
||||
Assert.Contains("1 active", bindingBadge!.TextContent);
|
||||
|
||||
// The native condition leaf nests under the binding and surfaces the enriched badge set.
|
||||
var leaf = binding.QuerySelector("ul[role='group'] li[role='treeitem']");
|
||||
Assert.NotNull(leaf);
|
||||
var leafMarkup = leaf!.OuterHtml;
|
||||
Assert.Contains("800", leafMarkup); // severity surfaced
|
||||
Assert.Contains("Shelved", leafMarkup); // composite condition badge
|
||||
Assert.Contains("OPC UA", leafMarkup); // native kind badge
|
||||
Assert.Contains("Unacked", leafMarkup); // active + unacknowledged
|
||||
Assert.Contains("ns=2;s=Tank01.Level.HiHi", leaf.TextContent); // source reference as the leaf label
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user