212 lines
10 KiB
C#
212 lines
10 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
{
|
|
private IRenderedComponent<DebugViewPage> RenderPage()
|
|
{
|
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
|
|
|
var repo = Substitute.For<ITemplateEngineRepository>();
|
|
var siteRepo = Substitute.For<ISiteRepository>();
|
|
siteRepo.GetAllSitesAsync().Returns(new List<Site>());
|
|
Services.AddSingleton(repo);
|
|
Services.AddSingleton(siteRepo);
|
|
|
|
var comms = new CommunicationService(
|
|
Options.Create(new CommunicationOptions()),
|
|
NullLogger<CommunicationService>.Instance);
|
|
Services.AddSingleton(comms);
|
|
|
|
var grpcFactory = new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance);
|
|
var debugStream = new DebugStreamService(
|
|
comms, new ServiceCollection().BuildServiceProvider(), grpcFactory,
|
|
NullLogger<DebugStreamService>.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<AuthenticationStateProvider>(stubAuth);
|
|
Services.AddScoped(_ => new SiteScopeService(stubAuth));
|
|
|
|
return Render<DebugViewPage>();
|
|
}
|
|
|
|
private sealed class StubAuthStateProvider : AuthenticationStateProvider
|
|
{
|
|
private readonly AuthenticationState _state;
|
|
public StubAuthStateProvider(AuthenticationState state) => _state = state;
|
|
public override Task<AuthenticationState> 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<string, AlarmStateChanged> AlarmStates(DebugViewPage c) =>
|
|
(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 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)
|
|
};
|
|
|
|
Connect(cut, Array.Empty<AttributeValueChanged>(), 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
|
|
}
|
|
}
|