Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs
T

299 lines
12 KiB
C#

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;
/// <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>();
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<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>())));
// 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<IReadOnlyList<AlarmStateChanged>>())
.Returns(ci =>
{
var alarms = ci.Arg<IReadOnlyList<AlarmStateChanged>>();
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<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 },
new("Site 2", "site2") { Id = 2 },
}));
Services.AddSingleton(_siteRepo);
Services.AddSingleton<ISiteAlarmLiveCache>(_liveCache);
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();
// ── 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<AlarmStateChanged>
{
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);
}
// ── arch-review R2 N4: stale-site poll guard ───────────────────────────────
[Fact]
public void InFlightPollForPreviousSite_DoesNotOverwriteNewSitesRows()
{
// Site 1's fan-out hangs on a TCS; site 2 answers immediately (arch-review R2 N4).
var site1Tcs = new TaskCompletionSource<AlarmSummaryResult>();
_summary.GetSiteAlarmsAsync(1, Arg.Any<CancellationToken>()).Returns(site1Tcs.Task);
_summary.GetSiteAlarmsAsync(2, Arg.Any<CancellationToken>()).Returns(Task.FromResult(
new AlarmSummaryResult(
new List<AlarmSummaryRow>
{
new("Site2Instance", new AlarmStateChanged("Site2Instance", "S2-alarm", AlarmState.Active, 100, DateTimeOffset.UtcNow)),
},
Array.Empty<string>())));
var cut = Render<AlarmSummaryPage>();
cut.Find("[data-test='alarm-summary-site']").Change("1"); // poll in flight, hung
cut.Find("[data-test='alarm-summary-site']").Change("2"); // site 2 renders
cut.WaitForAssertion(() => Assert.Equal("Site2Instance", FirstRowInstance(cut)));
// The stale site-1 fan-out now completes — it must be dropped, not rendered.
site1Tcs.SetResult(new AlarmSummaryResult(
new List<AlarmSummaryRow>
{
new("StaleSite1", new AlarmStateChanged("StaleSite1", "S1-alarm", AlarmState.Active, 999, DateTimeOffset.UtcNow)),
},
new[] { "StaleNotReporting" }));
cut.WaitForAssertion(() =>
{
Assert.Equal("Site2Instance", FirstRowInstance(cut));
Assert.DoesNotContain("StaleNotReporting", cut.Markup);
});
}
/// <summary>
/// Controllable in-memory fake of <see cref="ISiteAlarmLiveCache"/>: records
/// subscribe/dispose counts and lets a test push a live snapshot (which flips
/// <see cref="IsLive"/> true and fires the stored onChanged), so the page's
/// live-delta path can be driven deterministically.
/// </summary>
private sealed class FakeSiteAlarmLiveCache : ISiteAlarmLiveCache
{
private Action? _onChanged;
private IReadOnlyList<AlarmStateChanged> _current = Array.Empty<AlarmStateChanged>();
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<AlarmStateChanged> GetCurrentAlarms(int siteId) => _current;
public bool IsLive(int siteId) => _live;
public void PushAlarms(IReadOnlyList<AlarmStateChanged> 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;
}
}
}
}