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 @@
Instance
-
+
All
@foreach (var name in DistinctInstances)
{
@@ -119,7 +119,7 @@
Kind
-
+
All
Computed
OPC UA
@@ -128,7 +128,7 @@
State
-
+
All
Active
Normal
@@ -136,7 +136,7 @@
Ack
-
+
Any
Unacked
Acked
@@ -146,12 +146,12 @@
Min severity
+ @bind="_filterMinSeverity" @bind:after="RecomputeVisibleRows" />
Name search
+ placeholder="alarm name contains…" @bind="_filterName" @bind:event="oninput" @bind:after="RecomputeVisibleRows" />
Clear
@@ -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). *@
- SortBy("instance")'>Instance @SortGlyph("instance")
- SortBy("name")'>Alarm @SortGlyph("name")
+ SortBy("instance")'
+ @onkeydown='e => OnHeaderKeyDown(e, "instance")' @onkeydown:preventDefault="_preventHeaderDefault">Instance @SortGlyph("instance")
+ SortBy("name")'
+ @onkeydown='e => OnHeaderKeyDown(e, "name")' @onkeydown:preventDefault="_preventHeaderDefault">Alarm @SortGlyph("name")
State / Kind
- SortBy("severity")'>Severity @SortGlyph("severity")
+ SortBy("severity")'
+ @onkeydown='e => OnHeaderKeyDown(e, "severity")' @onkeydown:preventDefault="_preventHeaderDefault">Severity @SortGlyph("severity")
- @foreach (var row in filtered)
+ @foreach (var row in _visibleRows)
{
@row.InstanceUniqueName
@@ -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();
+}