feat(centralui): Site Call KPI tiles on the Health dashboard

This commit is contained in:
Joseph Doherty
2026-05-21 05:04:16 -04:00
parent d73b459057
commit 44f1ee372a
5 changed files with 482 additions and 0 deletions

View File

@@ -0,0 +1,177 @@
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using ScadaLink.CentralUI.Components.Health;
using ScadaLink.Commons.Messages.Audit;
namespace ScadaLink.CentralUI.Tests.Components.Health;
/// <summary>
/// bUnit tests for <see cref="SiteCallKpiTiles"/> (Site Call Audit #22, Task 7).
/// The component renders three Bootstrap-card tiles — Buffered, Stuck, Parked —
/// from a single <see cref="SiteCallKpiResponse"/> snapshot. The tests pin:
///
/// <list type="bullet">
/// <item>Three-tile render contract (data-test attributes for stable selectors).</item>
/// <item>Tile values render the snapshot's counters.</item>
/// <item>Threshold borders fire correctly — danger on Parked &gt; 0, warning
/// on Stuck &gt; 0, none when those counts are zero, none on Buffered.</item>
/// <item>Unavailable snapshot renders em dashes plus the error message.</item>
/// <item>Tile clicks navigate to the correct pre-filtered Site Calls report URL.</item>
/// </list>
/// </summary>
public class SiteCallKpiTilesTests : BunitContext
{
private static SiteCallKpiResponse MakeSnapshot(int buffered, int parked, int stuck) =>
new(
CorrelationId: "k",
Success: true,
ErrorMessage: null,
BufferedCount: buffered,
ParkedCount: parked,
FailedLastInterval: 0,
DeliveredLastInterval: 0,
OldestPendingAge: null,
StuckCount: stuck);
[Fact]
public void Renders_ThreeTiles_FromSnapshot()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 120, parked: 3, stuck: 7))
.Add(c => c.IsAvailable, true));
// Three stable data-test selectors — the contract for both these tests
// and any future Playwright sweep.
Assert.Contains("data-test=\"site-call-kpi-buffered\"", cut.Markup);
Assert.Contains("data-test=\"site-call-kpi-stuck\"", cut.Markup);
Assert.Contains("data-test=\"site-call-kpi-parked\"", cut.Markup);
// Tile values render the snapshot's counters.
Assert.Contains(">120<", cut.Markup); // buffered
Assert.Contains(">7<", cut.Markup); // stuck
Assert.Contains(">3<", cut.Markup); // parked
}
[Fact]
public void UnavailableSnapshot_RendersEmDashes_AndErrorMessage()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, (SiteCallKpiResponse?)null)
.Add(c => c.IsAvailable, false)
.Add(c => c.ErrorMessage, "site call repository unavailable"));
// All three tiles show em dashes — em dash (U+2014) "—" must appear.
Assert.Contains("—", cut.Markup);
// Inline error message renders below.
Assert.Contains("Site Call KPIs unavailable", cut.Markup);
Assert.Contains("site call repository unavailable", cut.Markup);
}
[Fact]
public void ParkedTile_GetsDangerBorder_WhenParkedAboveZero()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 4, stuck: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]");
Assert.Contains("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void ParkedTile_NoDangerBorder_WhenParkedZero()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 9, parked: 0, stuck: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]");
Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void StuckTile_GetsWarningBorder_WhenStuckAboveZero()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 0, stuck: 6))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]");
Assert.Contains("border-warning", tile.GetAttribute("class") ?? string.Empty);
// Warning, not danger — Stuck is the softer signal.
Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void StuckTile_NoWarningBorder_WhenStuckZero()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 9, parked: 0, stuck: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]");
Assert.DoesNotContain("border-warning", tile.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void BufferedTile_HasNoThresholdBorder_EvenWithHighCount()
{
// A non-zero buffer is normal operation — the Buffered tile is a plain
// count tile and never gets a danger/warning border.
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 5000, parked: 0, stuck: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-buffered\"]");
var cls = tile.GetAttribute("class") ?? string.Empty;
Assert.DoesNotContain("border-danger", cls);
Assert.DoesNotContain("border-warning", cls);
}
[Fact]
public void BufferedTile_Click_NavigatesToUnfilteredSiteCallsReport()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 50, parked: 0, stuck: 0))
.Add(c => c.IsAvailable, true));
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
var tile = cut.Find("[data-test=\"site-call-kpi-buffered\"]");
tile.Click();
// Unfiltered /site-calls/report — no query string.
Assert.EndsWith("/site-calls/report", nav.Uri);
}
[Fact]
public void StuckTile_Click_NavigatesToSiteCallsReport_WithStuckFilter()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 0, stuck: 6))
.Add(c => c.IsAvailable, true));
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]");
tile.Click();
// Spec: Stuck tile drills into the report's "stuck only" filter.
Assert.Contains("/site-calls/report?stuck=true", nav.Uri);
}
[Fact]
public void ParkedTile_Click_NavigatesToSiteCallsReport_WithParkedStatusFilter()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 4, stuck: 0))
.Add(c => c.IsAvailable, true));
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]");
tile.Click();
// Spec: Parked tile drills into ?status=Parked.
Assert.Contains("/site-calls/report?status=Parked", nav.Uri);
}
}

View File

@@ -9,6 +9,7 @@ using NSubstitute;
using ScadaLink.CentralUI.Services;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Messages.Audit;
using ScadaLink.Commons.Messages.Notification;
using ScadaLink.Commons.Types;
using ScadaLink.Communication;
@@ -37,6 +38,13 @@ public class HealthPageTests : BunitContext
new("k", true, null, QueueDepth: 12, StuckCount: 4, ParkedCount: 3,
DeliveredLastInterval: 88, OldestPendingAge: TimeSpan.FromMinutes(6));
// Site Call Audit (#22) Task 7 — mutable scripted Site Call KPI reply. Tests
// that target the Site Call tiles override this before rendering.
private SiteCallKpiResponse _siteCallKpiReply =
new("k", true, null, BufferedCount: 9, ParkedCount: 2, FailedLastInterval: 1,
DeliveredLastInterval: 40, OldestPendingAge: TimeSpan.FromMinutes(3),
StuckCount: 5);
public HealthPageTests()
{
_comms = new CommunicationService(
@@ -45,6 +53,9 @@ public class HealthPageTests : BunitContext
var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)));
_comms.SetNotificationOutbox(outbox);
var siteCallAudit = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this)));
_comms.SetSiteCallAudit(siteCallAudit);
Services.AddSingleton(_comms);
var aggregator = Substitute.For<ICentralHealthAggregator>();
@@ -133,6 +144,53 @@ public class HealthPageTests : BunitContext
});
}
[Fact]
public void Renders_SiteCallKpiTiles_WithValues()
{
var cut = Render<HealthPage>();
// KPI data arrives via an async actor Ask after first render.
cut.WaitForAssertion(() =>
{
Assert.Contains("Site Calls", cut.Markup);
// The three Site Call tiles render at the documented data-test selectors.
Assert.Contains("data-test=\"site-call-kpi-buffered\"", cut.Markup);
Assert.Contains("data-test=\"site-call-kpi-stuck\"", cut.Markup);
Assert.Contains("data-test=\"site-call-kpi-parked\"", cut.Markup);
// KPI numeric values surface in the tiles.
Assert.Contains(">9<", cut.Markup); // BufferedCount
Assert.Contains(">5<", cut.Markup); // StuckCount
Assert.Contains(">2<", cut.Markup); // ParkedCount
});
}
[Fact]
public void RendersLinkToTheSiteCallsReportPage()
{
var cut = Render<HealthPage>();
var link = cut.Find("a[href='/site-calls/report']");
Assert.Contains("View details", link.TextContent);
}
[Fact]
public void SiteCallKpiFailure_ShowsGracefulFallback()
{
_siteCallKpiReply = new SiteCallKpiResponse(
"k", false, "site call repository unavailable", 0, 0, 0, 0, null, 0);
var cut = Render<HealthPage>();
cut.WaitForAssertion(() =>
{
// Failure must not crash the page; tiles fall back to a dash and the
// inline error message surfaces.
Assert.Contains("Site Calls", cut.Markup);
Assert.Contains("Site Call KPIs unavailable", cut.Markup);
Assert.Contains("site call repository unavailable", cut.Markup);
Assert.Contains(">—<", cut.Markup);
});
}
[Fact]
public void OutboxKpiFailure_ShowsGracefulFallback()
{
@@ -170,4 +228,16 @@ public class HealthPageTests : BunitContext
Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply));
}
}
/// <summary>
/// Stand-in for the Site Call Audit actor. Replies to the KPI request with
/// the test's currently-scripted response.
/// </summary>
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
{
public ScriptedSiteCallAuditActor(HealthPageTests test)
{
Receive<SiteCallKpiRequest>(_ => Sender.Tell(test._siteCallKpiReply));
}
}
}