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.CentralUI.Components.Shared; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Communication; using AuditLogPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.ConfigurationAuditLog; using ParkedMessagesPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.ParkedMessages; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages; /// /// The detail modal/drawer on these surfaces is gated on a held id — user intent — /// rather than on the row still resolving, so that a data refresh can never unmount /// the subtree mid-render and dispose its event handlers (see /// NotificationReportDetailModalTests.Modal_StaysOpen_WhenItsRowLeavesThePage). /// /// That makes it the surface's own job to clear the id on navigation actions that /// invalidate page-scoped state, and the criterion is per-surface: /// /// • ParkedMessages and ConfigurationAuditLog have NO keyed detail fetch — their /// modal content resolves from the loaded page alone. An entry paged out of view /// can never resolve again, so the surface would sit on a permanently empty /// notice recoverable only by paging back. These must clear on paging. /// /// • NotificationReport and SiteCallsReport DO fetch detail by id. Their modal /// still shows real content after the row leaves the page, so they deliberately /// do NOT clear on paging, and are not covered here. /// /// Clearing on an explicit navigation action is intent, not a resolve-driven /// unmount, so it does not reopen the handler-disposal race. /// public class PageScopedDetailStateTests : BunitContext { private static void SetPrivate(object target, string field, object? value) => target.GetType() .GetField(field, BindingFlags.Instance | BindingFlags.NonPublic)! .SetValue(target, value); private static object? GetPrivate(object target, string field) => target.GetType() .GetField(field, BindingFlags.Instance | BindingFlags.NonPublic)! .GetValue(target); private static Task InvokePrivate(object target, string method, params object[] args) => (Task)target.GetType() .GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)! .Invoke(target, args)!; private void RegisterCommonServices() { 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)); Services.AddScoped(); JSInterop.Mode = JSRuntimeMode.Loose; } [Theory] [InlineData("PrevPage")] [InlineData("NextPage")] public async Task ParkedMessages_Paging_ClosesTheDrawer(string pagingMethod) { RegisterCommonServices(); var siteRepo = Substitute.For(); siteRepo.GetAllSitesAsync().Returns(new List()); Services.AddSingleton(siteRepo); Services.AddSingleton(new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance)); var cut = Render(); // Drawer open on some message. await cut.InvokeAsync(() => SetPrivate(cut.Instance, "_drawerMessageId", "msg-being-viewed")); Assert.Equal("msg-being-viewed", GetPrivate(cut.Instance, "_drawerMessageId")); await cut.InvokeAsync(() => InvokePrivate(cut.Instance, pagingMethod)); // Paging away invalidates it — this surface has no keyed detail to fall back // on, so leaving it set would strand the drawer on an empty notice. Assert.Null(GetPrivate(cut.Instance, "_drawerMessageId")); } [Fact] public async Task ConfigurationAuditLog_Paging_ClosesTheStateModal() { RegisterCommonServices(); Services.AddSingleton(Substitute.For()); var cut = Render(); await cut.InvokeAsync(() => SetPrivate(cut.Instance, "_modalEntryId", 4242)); Assert.Equal(4242, GetPrivate(cut.Instance, "_modalEntryId")); await cut.InvokeAsync(() => InvokePrivate(cut.Instance, "OnPageChanged", 2)); Assert.Null(GetPrivate(cut.Instance, "_modalEntryId")); } private sealed class StubAuthStateProvider : AuthenticationStateProvider { private readonly AuthenticationState _state; public StubAuthStateProvider(AuthenticationState state) => _state = state; public override Task GetAuthenticationStateAsync() => Task.FromResult(_state); } }