From 75bf14a35a6627f4fe93024dd1965700d7664691 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 10 Jul 2026 05:15:36 -0400 Subject: [PATCH] chore(ui): memoize AlarmSummary rows + keyboard/aria-sort on sortable headers (arch-review P4/UA6) Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj --- .../Pages/Monitoring/AlarmSummary.razor | 70 +++++++++-- .../Monitoring/AlarmSummaryRenderTests.cs | 112 ++++++++++++++++++ 2 files changed, 170 insertions(+), 12 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor index 9dadcb1d..1ab17c31 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor @@ -109,7 +109,7 @@
- @foreach (var name in DistinctInstances) { @@ -119,7 +119,7 @@
- @@ -128,7 +128,7 @@
- @@ -136,7 +136,7 @@
- @@ -146,12 +146,12 @@ + @bind="_filterMinSeverity" @bind:after="RecomputeVisibleRows" />
+ placeholder="alarm name contains…" @bind="_filterName" @bind:event="oninput" @bind:after="RecomputeVisibleRows" />
@@ -167,20 +167,30 @@ } else { - var filtered = FilteredRows().ToList(); -
Showing @filtered.Count of @_rows.Count
+
Showing @_visibleRows.Count of @_rows.Count
+ @* UA6 (arch-review): sortable headers are keyboard-operable — tabindex="0", + aria-sort reflects the active sort direction, and Enter/Space toggle the + sort (Space preventDefault suppresses page scroll). The same pattern should + be applied to the other custom grids in the app; that fleet-wide sweep is + deferred and logged (arch-review UA6). *@ - - + + - + - @foreach (var row in filtered) + @foreach (var row in _visibleRows) { @@ -201,6 +211,12 @@ private int? _selectedSiteId; private IReadOnlyList _rows = Array.Empty(); + + // P4 (arch-review): the filtered+sorted view is memoized rather than recomputed + // on every render. RecomputeVisibleRows() refreshes it whenever the inputs change + // (a fresh snapshot in RefreshAsync, a filter change via @bind:after, or a sort). + private IReadOnlyList _visibleRows = Array.Empty(); + private IReadOnlyList _notReporting = Array.Empty(); private AlarmRollup _rollup = new(0, 0, 0, new Dictionary()); private bool _loading; @@ -266,6 +282,7 @@ _rows = result.Alarms; _notReporting = result.NotReportingInstances; _rollup = AlarmSummaryService.ComputeRollup(_rows); + RecomputeVisibleRows(); } catch { @@ -308,6 +325,11 @@ private IEnumerable DistinctInstances => _rows.Select(r => r.InstanceUniqueName).Distinct().OrderBy(n => n, StringComparer.OrdinalIgnoreCase); + // P4: recompute the memoized filtered+sorted view from the current snapshot and + // filter/sort inputs. Called from RefreshAsync, each filter's @bind:after, and SortBy — + // NOT per render. + private void RecomputeVisibleRows() => _visibleRows = FilteredRows().ToList(); + private IEnumerable FilteredRows() { IEnumerable q = _rows; @@ -366,11 +388,34 @@ _sortKey = key; _sortDescending = key == "severity"; } + RecomputeVisibleRows(); } private string SortGlyph(string key) => _sortKey != key ? "" : (_sortDescending ? "▼" : "▲"); + // UA6: aria-sort token for a sortable header — ascending/descending on the active + // column, "none" otherwise (per the WAI-ARIA aria-sort value set). + private string AriaSortFor(string key) => + _sortKey != key ? "none" : (_sortDescending ? "descending" : "ascending"); + + // UA6: whether the last header keydown should suppress the browser default. Bound to + // @onkeydown:preventDefault (evaluated at render), so it takes effect on the next + // Space keydown to stop the page scrolling when a header has keyboard focus. + private bool _preventHeaderDefault; + + // UA6: keyboard activation for the sortable headers — Enter or Space toggles the sort, + // mirroring the pointer click. + private void OnHeaderKeyDown(KeyboardEventArgs e, string key) + { + var isSpace = e.Key is " " or "Spacebar"; + _preventHeaderDefault = isSpace; // suppress Space page-scroll; leave Tab/others alone + if (e.Key == "Enter" || isSpace) + { + SortBy(key); + } + } + private void ClearFilters() { _filterInstance = ""; @@ -379,6 +424,7 @@ _filterAck = ""; _filterMinSeverity = null; _filterName = ""; + RecomputeVisibleRows(); } private static string KindLabel(AlarmKind kind) => kind switch diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs new file mode 100644 index 00000000..3a93a100 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs @@ -0,0 +1,112 @@ +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.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(); + + 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()))); + _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 } })); + Services.AddSingleton(_siteRepo); + + 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(); +}
Instance @SortGlyph("instance")Alarm @SortGlyph("name")Instance @SortGlyph("instance")Alarm @SortGlyph("name") State / KindSeverity @SortGlyph("severity")Severity @SortGlyph("severity")
@row.InstanceUniqueName