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 } }