chore(ui): memoize AlarmSummary rows + keyboard/aria-sort on sortable headers (arch-review P4/UA6)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>tabindex="0"</c>, expose their sort direction via
|
||||
/// <c>aria-sort</c> (<c>ascending</c>/<c>descending</c> on the active column,
|
||||
/// <c>none</c> otherwise), and toggle the sort when Enter is pressed on them —
|
||||
/// mirroring the existing pointer-click sort.
|
||||
/// </summary>
|
||||
public class AlarmSummaryRenderTests : BunitContext
|
||||
{
|
||||
private readonly IAlarmSummaryService _summary = Substitute.For<IAlarmSummaryService>();
|
||||
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||
|
||||
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<AlarmSummaryRow>
|
||||
{
|
||||
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<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(new AlarmSummaryResult(rows, Array.Empty<string>())));
|
||||
_summary.ComputeRollup(Arg.Any<IReadOnlyList<AlarmSummaryRow>>())
|
||||
.Returns(new AlarmRollup(2, 900, 0, new Dictionary<AlarmKind, int>()));
|
||||
Services.AddSingleton(_summary);
|
||||
|
||||
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site> { 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<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
}
|
||||
|
||||
// Renders the page and drives the site picker so the alarm table appears.
|
||||
private IRenderedComponent<AlarmSummaryPage> RenderWithSiteSelected()
|
||||
{
|
||||
var cut = Render<AlarmSummaryPage>();
|
||||
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<AlarmSummaryPage> cut) =>
|
||||
cut.FindAll("tr[data-test='alarm-summary-row']")[0]
|
||||
.QuerySelector("td")!.TextContent.Trim();
|
||||
}
|
||||
Reference in New Issue
Block a user