using System.Reflection;
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.DebugView;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
///
/// DV-5: the Debug View renders a tabbed (Attributes / Alarms) layout, each tab
/// hosting a composition TreeView. 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.
///
public class DebugViewAlarmTableTests : BunitContext
{
private IRenderedComponent RenderPage()
{
JSInterop.Mode = JSRuntimeMode.Loose;
var repo = Substitute.For();
var siteRepo = Substitute.For();
siteRepo.GetAllSitesAsync().Returns(new List());
Services.AddSingleton(repo);
Services.AddSingleton(siteRepo);
var comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger.Instance);
Services.AddSingleton(comms);
var grpcFactory = new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance);
var debugStream = new DebugStreamService(
comms, new ServiceCollection().BuildServiceProvider(), grpcFactory,
NullLogger.Instance);
Services.AddSingleton(debugStream);
var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "deployer") }, "TestCookie");
var stubAuth = new StubAuthStateProvider(new AuthenticationState(new ClaimsPrincipal(identity)));
Services.AddSingleton(stubAuth);
Services.AddScoped(_ => new SiteScopeService(stubAuth));
return Render();
}
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
public StubAuthStateProvider(AuthenticationState state) => _state = state;
public override Task GetAuthenticationStateAsync() => Task.FromResult(_state);
}
private static void SetField(DebugViewPage c, string name, object? value) =>
typeof(DebugViewPage).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(c, value);
private static Dictionary AlarmStates(DebugViewPage c) =>
(Dictionary)typeof(DebugViewPage)
.GetField("_alarmStates", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(c)!;
private static Dictionary AttributeValues(DebugViewPage c) =>
(Dictionary)typeof(DebugViewPage)
.GetField("_attributeValues", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(c)!;
///
/// Mark the page connected with the given snapshot and seed the latest-per-name
/// dictionaries, then re-render. Mirrors what Connect() does from a real
/// snapshot without driving the gRPC stream.
///
private static void Connect(
IRenderedComponent cut,
IReadOnlyList attributes,
IReadOnlyList 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 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(), 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)
};
Connect(cut, Array.Empty(), new[] { native });
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("sev 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
}
}