using System.Collections; 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.Streaming; 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; /// /// Regression tests for the Debug View render coalescing (arch-review WP2.4). Every streamed /// event used to marshal onto the circuit dispatcher and call StateHasChanged on its /// own, so a chatty instance drove one full render — and two full composition-tree rebuilds — /// per value change. Events are now buffered and applied once per ~250 ms window: a burst /// costs a handful of renders instead of one per event, and no event is dropped. /// public class DebugViewRenderCoalescingTests : 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 MethodInfo HandleStreamEvent => typeof(DebugViewPage).GetMethod( "HandleStreamEvent", BindingFlags.Instance | BindingFlags.NonPublic)!; private static IDictionary AttributeValues(DebugViewPage c) => (IDictionary) typeof(DebugViewPage).GetField("_attributeValues", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(c)!; private static void Send(DebugViewPage page, string name, object value) => HandleStreamEvent.Invoke(page, new object[] { new AttributeValueChanged("Inst-1", name, name, value, "Good", DateTimeOffset.UtcNow), }); [Fact] public void BurstOfEvents_RendersOnce_ButAppliesEveryEvent() { var cut = RenderPage(); var dict = AttributeValues(cut.Instance); var rendersBefore = cut.RenderCount; const int burst = 300; for (var i = 0; i < burst; i++) { Send(cut.Instance, $"Tag.{i}", i); } cut.WaitForState(() => dict.Count == burst, TimeSpan.FromSeconds(5)); // Pre-fix this was one render per event. The window may close mid-burst, so allow a // few flushes — the point is that it is a small constant, not O(events). var renders = cut.RenderCount - rendersBefore; Assert.InRange(renders, 1, 5); } [Fact] public void RepeatedUpdatesToOneTag_CollapseToTheLatestValue() { var cut = RenderPage(); var dict = AttributeValues(cut.Instance); for (var i = 0; i < 50; i++) { Send(cut.Instance, "Pump.Speed", i); } cut.WaitForState(() => dict.Count == 1, TimeSpan.FromSeconds(5)); var applied = (AttributeValueChanged)dict["Pump.Speed"]!; Assert.Equal(49, applied.Value); } [Fact] public void EventArrivingAfterAFlush_StillArmsANewWindow() { var cut = RenderPage(); var dict = AttributeValues(cut.Instance); Send(cut.Instance, "First", 1); cut.WaitForState(() => dict.Count == 1, TimeSpan.FromSeconds(5)); // The window disarms on drain; a later event must arm a fresh one rather than // sitting in the pending map until some unrelated event happens to arrive. Send(cut.Instance, "Second", 2); cut.WaitForState(() => dict.Count == 2, TimeSpan.FromSeconds(5)); } [Fact] public void EventsAfterDispose_AreDroppedWithoutThrowing() { var cut = RenderPage(); cut.Instance.Dispose(); var ex = Record.Exception(() => Send(cut.Instance, "Late", 1)); Assert.Null(ex); Assert.Empty(AttributeValues(cut.Instance)); } }