using System.Security.Claims; using Bunit; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using ZB.MOM.WW.ScadaBridge.CentralUI.Services; 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.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Communication; using ZB.MOM.WW.ScadaBridge.Security; using AlarmSummaryPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.AlarmSummary; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring; /// /// bUnit rendering tests for the Operator Alarm Summary page — arch-review UA6 /// (sortable-header accessibility). The sortable column headers must be keyboard /// operable: they carry tabindex="0", expose their sort direction via /// aria-sort (ascending/descending on the active column, /// none otherwise), and toggle the sort when Enter is pressed on them — /// mirroring the existing pointer-click sort. /// public class AlarmSummaryRenderTests : BunitContext { private readonly IAlarmSummaryService _summary = Substitute.For(); private readonly ISiteRepository _siteRepo = Substitute.For(); private readonly FakeSiteAlarmLiveCache _liveCache = new(); public AlarmSummaryRenderTests() { // Two rows whose default sort (severity DESC) orders Zeta (900) before // Alpha (500); an ascending sort by instance name flips them. var rows = new List { new("Zeta", new AlarmStateChanged("Zeta", "Z-alarm", AlarmState.Active, 900, DateTimeOffset.UtcNow)), new("Alpha", new AlarmStateChanged("Alpha", "A-alarm", AlarmState.Active, 500, DateTimeOffset.UtcNow)), }; _summary.GetSiteAlarmsAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new AlarmSummaryResult(rows, Array.Empty()))); // BuildFromLiveAlarms (Task 5): flatten the pushed live snapshot the same way // the real service does, so the live-path render tests exercise real behavior. _summary.BuildFromLiveAlarms(Arg.Any>()) .Returns(ci => { var alarms = ci.Arg>(); var built = alarms .Select(a => new AlarmSummaryRow(a.InstanceUniqueName, a)) .OrderBy(r => r.InstanceUniqueName, StringComparer.OrdinalIgnoreCase) .ThenBy(r => r.Alarm.AlarmName, StringComparer.OrdinalIgnoreCase) .ToList(); return new AlarmSummaryResult(built, Array.Empty()); }); _summary.ComputeRollup(Arg.Any>()) .Returns(new AlarmRollup(2, 900, 0, new Dictionary())); Services.AddSingleton(_summary); _siteRepo.GetAllSitesAsync(Arg.Any()) .Returns(Task.FromResult>(new List { new("Site 1", "site1") { Id = 1 }, new("Site 2", "site2") { Id = 2 }, })); Services.AddSingleton(_siteRepo); Services.AddSingleton(_liveCache); var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(JwtTokenService.RoleClaimType, "Administrator"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); Services.AddAuthorizationCore(); } // Renders the page and drives the site picker so the alarm table appears. private IRenderedComponent RenderWithSiteSelected() { var cut = Render(); cut.Find("[data-test='alarm-summary-site']").Change("1"); cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll("tr[data-test='alarm-summary-row']"))); return cut; } [Fact] public void SortableHeaders_CarryTabindexAndAriaSort() { var cut = RenderWithSiteSelected(); var headers = cut.FindAll("thead th"); // 0=Instance (sortable), 1=Alarm (sortable), 2=State/Kind (not sortable), 3=Severity (sortable, active). var instance = headers[0]; var stateKind = headers[2]; var severity = headers[3]; Assert.Equal("0", instance.GetAttribute("tabindex")); Assert.Equal("0", severity.GetAttribute("tabindex")); // Default sort is severity descending. Assert.Equal("descending", severity.GetAttribute("aria-sort")); Assert.Equal("none", instance.GetAttribute("aria-sort")); // The non-sortable column is not keyboard-focusable and carries no aria-sort. Assert.Null(stateKind.GetAttribute("tabindex")); Assert.Null(stateKind.GetAttribute("aria-sort")); } [Fact] public void EnterOnInstanceHeader_FlipsRowOrder() { var cut = RenderWithSiteSelected(); // Default severity-descending order → Zeta (900) first. Assert.Equal("Zeta", FirstRowInstance(cut)); // Enter on the Instance header sorts ascending by instance → Alpha first. cut.FindAll("thead th")[0].TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "Enter" }); cut.WaitForAssertion(() => Assert.Equal("Alpha", FirstRowInstance(cut))); // aria-sort now reflects the active ascending instance column. Assert.Equal("ascending", cut.FindAll("thead th")[0].GetAttribute("aria-sort")); } private static string FirstRowInstance(IRenderedComponent cut) => cut.FindAll("tr[data-test='alarm-summary-row']")[0] .QuerySelector("td")!.TextContent.Trim(); // ── plan #10 Task 5: live-cache-first with poll fallback ─────────────────── [Fact] public void SelectingSite_SubscribesToTheLiveCache() { RenderWithSiteSelected(); Assert.Equal(1, _liveCache.SubscribeCount); Assert.Equal(1, _liveCache.LastSubscribedSite); } [Fact] public void PollFallbackRenders_WhenCacheNotLiveYet() { // Cache starts not-live, so the site select falls back to the poll's // GetSiteAlarmsAsync snapshot (Zeta + Alpha). Assert.False(_liveCache.IsLive(1)); var cut = RenderWithSiteSelected(); Assert.Equal(2, cut.FindAll("tr[data-test='alarm-summary-row']").Count); Assert.Equal("Zeta", FirstRowInstance(cut)); // severity-desc default } [Fact] public void OnChangedDelta_RebuildsRenderedRowsFromLiveSnapshot() { var cut = RenderWithSiteSelected(); // Push a fresh live snapshot: a single new alarm on a new instance. _liveCache.PushAlarms(new List { new("Gamma", "G-alarm", AlarmState.Active, 300, DateTimeOffset.UtcNow), }); cut.WaitForAssertion(() => { var rows = cut.FindAll("tr[data-test='alarm-summary-row']"); Assert.Single(rows); Assert.Equal("Gamma", FirstRowInstance(cut)); }); } [Fact] public void LeavingSite_UnsubscribesFromTheLiveCache() { var cut = RenderWithSiteSelected(); Assert.Equal(0, _liveCache.DisposeCount); // Clearing the site picker tears the subscription down. cut.Find("[data-test='alarm-summary-site']").Change(""); Assert.Equal(1, _liveCache.DisposeCount); } [Fact] public void ChangingSite_ResubscribesToTheNewSite() { var cut = RenderWithSiteSelected(); // site 1 cut.Find("[data-test='alarm-summary-site']").Change("2"); Assert.Equal(2, _liveCache.SubscribeCount); // one per site Assert.Equal(1, _liveCache.DisposeCount); // old subscription disposed Assert.Equal(2, _liveCache.LastSubscribedSite); } [Fact] public void DisposingComponent_UnsubscribesFromTheLiveCache() { var cut = RenderWithSiteSelected(); cut.Instance.Dispose(); Assert.True(_liveCache.DisposeCount >= 1); } /// /// Controllable in-memory fake of : records /// subscribe/dispose counts and lets a test push a live snapshot (which flips /// true and fires the stored onChanged), so the page's /// live-delta path can be driven deterministically. /// private sealed class FakeSiteAlarmLiveCache : ISiteAlarmLiveCache { private Action? _onChanged; private IReadOnlyList _current = Array.Empty(); private bool _live; public int SubscribeCount { get; private set; } public int DisposeCount { get; private set; } public int? LastSubscribedSite { get; private set; } public IDisposable Subscribe(int siteId, Action onChanged) { SubscribeCount++; LastSubscribedSite = siteId; _onChanged = onChanged; return new Subscription(this); } public IReadOnlyList GetCurrentAlarms(int siteId) => _current; public bool IsLive(int siteId) => _live; public void PushAlarms(IReadOnlyList alarms) { _current = alarms; _live = true; _onChanged?.Invoke(); } private sealed class Subscription : IDisposable { private readonly FakeSiteAlarmLiveCache _owner; private bool _disposed; public Subscription(FakeSiteAlarmLiveCache owner) => _owner = owner; public void Dispose() { if (_disposed) return; _disposed = true; _owner.DisposeCount++; _owner._onChanged = null; } } } }