feat(centralui): Site Call KPI tiles on the Health dashboard
This commit is contained in:
@@ -0,0 +1,60 @@
|
|||||||
|
@*
|
||||||
|
Site Call Audit (#22) Task 7 — three Health-dashboard KPI tiles for the
|
||||||
|
Site Call channel: Buffered / Parked / Stuck. Renders Bootstrap card tiles
|
||||||
|
in a single row, each acting as a navigation link to a pre-filtered Site
|
||||||
|
Calls report view. The component is purely presentational — the parent page
|
||||||
|
owns the refresh loop and passes the latest snapshot via the Snapshot
|
||||||
|
parameter. Mirrors AuditKpiTiles and the Notification Outbox KPI section.
|
||||||
|
*@
|
||||||
|
|
||||||
|
@namespace ScadaLink.CentralUI.Components.Health
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h6 class="text-muted mb-0">Site Calls</h6>
|
||||||
|
<a class="small" href="/site-calls/report">View details →</a>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
@* ── Buffered tile ─────────────────────────────────────────────────────── *@
|
||||||
|
<div class="col-lg-4 col-md-6 col-12">
|
||||||
|
<button type="button"
|
||||||
|
class="card h-100 w-100 text-start border-0 shadow-none p-0 site-call-kpi-tile"
|
||||||
|
data-test="site-call-kpi-buffered"
|
||||||
|
@onclick="NavigateToBuffered">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h3 class="mb-0">@BufferedDisplay</h3>
|
||||||
|
<small class="text-muted">Buffered</small>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Stuck tile ────────────────────────────────────────────────────────── *@
|
||||||
|
<div class="col-lg-4 col-md-6 col-12">
|
||||||
|
<button type="button"
|
||||||
|
class="card h-100 w-100 text-start border-0 shadow-none p-0 site-call-kpi-tile @StuckBorderClass"
|
||||||
|
data-test="site-call-kpi-stuck"
|
||||||
|
@onclick="NavigateToStuck">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h3 class="mb-0 @StuckTextClass">@StuckDisplay</h3>
|
||||||
|
<small class="text-muted">Stuck</small>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Parked tile ───────────────────────────────────────────────────────── *@
|
||||||
|
<div class="col-lg-4 col-md-6 col-12">
|
||||||
|
<button type="button"
|
||||||
|
class="card h-100 w-100 text-start border-0 shadow-none p-0 site-call-kpi-tile @ParkedBorderClass"
|
||||||
|
data-test="site-call-kpi-parked"
|
||||||
|
@onclick="NavigateToParked">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h3 class="mb-0 @ParkedTextClass">@ParkedDisplay</h3>
|
||||||
|
<small class="text-muted">Parked</small>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (!IsAvailable && !string.IsNullOrEmpty(ErrorMessage))
|
||||||
|
{
|
||||||
|
<div class="text-muted small mb-3">Site Call KPIs unavailable: @ErrorMessage</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using ScadaLink.Commons.Messages.Audit;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Components.Health;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Site Call Audit (#22) Task 7 code-behind for <see cref="SiteCallKpiTiles"/>.
|
||||||
|
/// Renders three KPI tiles — Buffered, Stuck, Parked — from a
|
||||||
|
/// <see cref="SiteCallKpiResponse"/> the parent Health dashboard supplies.
|
||||||
|
/// Tiles act as drill-in links: clicking navigates to <c>/site-calls/report</c>
|
||||||
|
/// with the relevant query-string filter pre-applied. Mirrors
|
||||||
|
/// <see cref="AuditKpiTiles"/> and the Notification Outbox KPI section on the
|
||||||
|
/// Health dashboard.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why purely presentational.</b> The Health dashboard already owns a 10s
|
||||||
|
/// auto-refresh loop; pushing that into the tile component would either
|
||||||
|
/// duplicate it (one timer per tile) or awkwardly couple back to the page. The
|
||||||
|
/// parent passes a fresh <see cref="SiteCallKpiResponse"/> every refresh and the
|
||||||
|
/// tile component re-renders. This is the same contract <see cref="AuditKpiTiles"/>
|
||||||
|
/// follows.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Snapshot shape.</b> Unlike <see cref="AuditKpiTiles"/> — which takes a
|
||||||
|
/// dedicated <c>AuditLogKpiSnapshot</c> type — Site Call KPIs travel in the
|
||||||
|
/// <see cref="SiteCallKpiResponse"/> message itself (it carries the KPI fields
|
||||||
|
/// directly), so that record doubles as the snapshot here. <see cref="IsAvailable"/>
|
||||||
|
/// is a separate flag rather than the record's own <c>Success</c> so the parent
|
||||||
|
/// can also surface a transport failure (an Ask that threw) as unavailable.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Threshold borders.</b> Mirrors the Notification Outbox tile pattern: the
|
||||||
|
/// Parked tile gets a danger border when <c>ParkedCount > 0</c>; the Stuck
|
||||||
|
/// tile gets a warning border when <c>StuckCount > 0</c>. Buffered is a plain
|
||||||
|
/// count tile with no threshold colour — a non-zero buffer is normal operation.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public partial class SiteCallKpiTiles
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Latest KPI snapshot. <c>null</c> means the parent has not loaded it yet
|
||||||
|
/// or the load failed — the tiles render em dashes in that case.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public SiteCallKpiResponse? Snapshot { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when <see cref="Snapshot"/> is a successful query result. False when
|
||||||
|
/// the parent's refresh threw, or the response itself reported a fault, and
|
||||||
|
/// the displayed values should be rendered as em dashes with an error
|
||||||
|
/// explanation underneath.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public bool IsAvailable { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional error message to render underneath the tiles when
|
||||||
|
/// <see cref="IsAvailable"/> is false. Mirrors how the Notification Outbox
|
||||||
|
/// section on the Health dashboard surfaces transient KPI failures.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public string? ErrorMessage { get; set; }
|
||||||
|
|
||||||
|
// ── Buffered tile ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private string BufferedDisplay =>
|
||||||
|
IsAvailable && Snapshot is not null
|
||||||
|
? Snapshot.BufferedCount.ToString("N0")
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
private void NavigateToBuffered()
|
||||||
|
{
|
||||||
|
// Buffered is "everything still in flight" — no single status maps to
|
||||||
|
// it, so the natural drill-in is the unfiltered Site Calls report sorted
|
||||||
|
// by newest, mirroring how the Audit volume/backlog tiles drop the
|
||||||
|
// operator on the unfiltered Audit Log grid.
|
||||||
|
Navigation.NavigateTo("/site-calls/report");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stuck tile ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private string StuckDisplay =>
|
||||||
|
IsAvailable && Snapshot is not null
|
||||||
|
? Snapshot.StuckCount.ToString("N0")
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
// Stuck above zero is a warning signal — cached calls that have been
|
||||||
|
// Pending/Retrying past the stuck-age threshold. Matches the Notification
|
||||||
|
// Outbox Stuck tile (border-warning when StuckCount > 0).
|
||||||
|
private string StuckBorderClass =>
|
||||||
|
IsAvailable && Snapshot is not null && Snapshot.StuckCount > 0
|
||||||
|
? "border-warning"
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
private string StuckTextClass =>
|
||||||
|
IsAvailable && Snapshot is not null && Snapshot.StuckCount > 0
|
||||||
|
? "text-warning"
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
private void NavigateToStuck()
|
||||||
|
{
|
||||||
|
// Drill in with the report's "stuck only" filter pre-applied.
|
||||||
|
Navigation.NavigateTo("/site-calls/report?stuck=true");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parked tile ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private string ParkedDisplay =>
|
||||||
|
IsAvailable && Snapshot is not null
|
||||||
|
? Snapshot.ParkedCount.ToString("N0")
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
// Parked above zero is a danger signal — cached calls that exhausted retries
|
||||||
|
// and need an operator Retry/Discard. Matches the Notification Outbox Parked
|
||||||
|
// tile (border-danger when ParkedCount > 0).
|
||||||
|
private string ParkedBorderClass =>
|
||||||
|
IsAvailable && Snapshot is not null && Snapshot.ParkedCount > 0
|
||||||
|
? "border-danger"
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
private string ParkedTextClass =>
|
||||||
|
IsAvailable && Snapshot is not null && Snapshot.ParkedCount > 0
|
||||||
|
? "text-danger"
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
private void NavigateToParked()
|
||||||
|
{
|
||||||
|
// Drill in pre-filtered to Parked — the report's Status filter accepts
|
||||||
|
// ?status=Parked and Parked rows carry the Retry/Discard relay actions.
|
||||||
|
Navigation.NavigateTo("/site-calls/report?status=Parked");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
@using ScadaLink.Commons.Interfaces.Repositories
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
@using ScadaLink.HealthMonitoring
|
@using ScadaLink.HealthMonitoring
|
||||||
@using ScadaLink.Commons.Messages.Notification
|
@using ScadaLink.Commons.Messages.Notification
|
||||||
|
@using ScadaLink.Commons.Messages.Audit
|
||||||
@using ScadaLink.Communication
|
@using ScadaLink.Communication
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
@inject ICentralHealthAggregator HealthAggregator
|
@inject ICentralHealthAggregator HealthAggregator
|
||||||
@@ -60,6 +61,12 @@
|
|||||||
<div class="text-muted small mb-3">Notification Outbox KPIs unavailable: @_outboxKpiError</div>
|
<div class="text-muted small mb-3">Notification Outbox KPIs unavailable: @_outboxKpiError</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* Site Call Audit (#22) Task 7 — three KPI tiles for the Site Call channel
|
||||||
|
(buffered / stuck / parked). Refreshed alongside the site states. *@
|
||||||
|
<SiteCallKpiTiles Snapshot="@_siteCallKpi"
|
||||||
|
IsAvailable="@_siteCallKpiAvailable"
|
||||||
|
ErrorMessage="@_siteCallKpiError" />
|
||||||
|
|
||||||
@* Audit Log (#23) M7 Bundle E — three KPI tiles for the Audit channel
|
@* Audit Log (#23) M7 Bundle E — three KPI tiles for the Audit channel
|
||||||
(volume / error rate / backlog). Refreshed alongside the site states. *@
|
(volume / error rate / backlog). Refreshed alongside the site states. *@
|
||||||
<AuditKpiTiles Snapshot="@_auditKpi"
|
<AuditKpiTiles Snapshot="@_auditKpi"
|
||||||
@@ -364,6 +371,13 @@
|
|||||||
private bool _auditKpiAvailable;
|
private bool _auditKpiAvailable;
|
||||||
private string? _auditKpiError;
|
private string? _auditKpiError;
|
||||||
|
|
||||||
|
// Site Call Audit (#22) Task 7 — Site Call KPI tiles. Point-in-time counts
|
||||||
|
// from the central SiteCalls table, fetched alongside the site states. The
|
||||||
|
// SiteCallKpiResponse message doubles as the snapshot the tile takes.
|
||||||
|
private SiteCallKpiResponse? _siteCallKpi;
|
||||||
|
private bool _siteCallKpiAvailable;
|
||||||
|
private string? _siteCallKpiError;
|
||||||
|
|
||||||
private static bool SiteHasActiveErrors(SiteHealthState state)
|
private static bool SiteHasActiveErrors(SiteHealthState state)
|
||||||
{
|
{
|
||||||
var report = state.LatestReport;
|
var report = state.LatestReport;
|
||||||
@@ -401,6 +415,7 @@
|
|||||||
{
|
{
|
||||||
_siteStates = HealthAggregator.GetAllSiteStates();
|
_siteStates = HealthAggregator.GetAllSiteStates();
|
||||||
await LoadOutboxKpis();
|
await LoadOutboxKpis();
|
||||||
|
await LoadSiteCallKpis();
|
||||||
await LoadAuditKpis();
|
await LoadAuditKpis();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +444,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Site Call KPI loader: wraps the service call so a transient fault degrades
|
||||||
|
// the three Site Call tiles to em dashes with an inline error rather than
|
||||||
|
// killing the dashboard. Mirrors LoadOutboxKpis's error handling shape — a
|
||||||
|
// response with Success == false (repository fault) and an Ask that threw
|
||||||
|
// (transport fault) both collapse to "unavailable".
|
||||||
|
private async Task LoadSiteCallKpis()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await CommunicationService.GetSiteCallKpisAsync(
|
||||||
|
new SiteCallKpiRequest(Guid.NewGuid().ToString("N")));
|
||||||
|
if (response.Success)
|
||||||
|
{
|
||||||
|
_siteCallKpi = response;
|
||||||
|
_siteCallKpiAvailable = true;
|
||||||
|
_siteCallKpiError = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_siteCallKpiAvailable = false;
|
||||||
|
_siteCallKpiError = response.ErrorMessage ?? "KPI query failed.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_siteCallKpiAvailable = false;
|
||||||
|
_siteCallKpiError = $"KPI query failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tiles show the numeric KPI when available, or an em dash when the outbox
|
// Tiles show the numeric KPI when available, or an em dash when the outbox
|
||||||
// KPI query failed — matching how the page renders other unavailable data.
|
// KPI query failed — matching how the page renders other unavailable data.
|
||||||
private string OutboxTileValue(int value) =>
|
private string OutboxTileValue(int value) =>
|
||||||
|
|||||||
@@ -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 > 0, warning
|
||||||
|
/// on Stuck > 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ using NSubstitute;
|
|||||||
using ScadaLink.CentralUI.Services;
|
using ScadaLink.CentralUI.Services;
|
||||||
using ScadaLink.Commons.Entities.Sites;
|
using ScadaLink.Commons.Entities.Sites;
|
||||||
using ScadaLink.Commons.Interfaces.Repositories;
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Messages.Audit;
|
||||||
using ScadaLink.Commons.Messages.Notification;
|
using ScadaLink.Commons.Messages.Notification;
|
||||||
using ScadaLink.Commons.Types;
|
using ScadaLink.Commons.Types;
|
||||||
using ScadaLink.Communication;
|
using ScadaLink.Communication;
|
||||||
@@ -37,6 +38,13 @@ public class HealthPageTests : BunitContext
|
|||||||
new("k", true, null, QueueDepth: 12, StuckCount: 4, ParkedCount: 3,
|
new("k", true, null, QueueDepth: 12, StuckCount: 4, ParkedCount: 3,
|
||||||
DeliveredLastInterval: 88, OldestPendingAge: TimeSpan.FromMinutes(6));
|
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()
|
public HealthPageTests()
|
||||||
{
|
{
|
||||||
_comms = new CommunicationService(
|
_comms = new CommunicationService(
|
||||||
@@ -45,6 +53,9 @@ public class HealthPageTests : BunitContext
|
|||||||
|
|
||||||
var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)));
|
var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)));
|
||||||
_comms.SetNotificationOutbox(outbox);
|
_comms.SetNotificationOutbox(outbox);
|
||||||
|
|
||||||
|
var siteCallAudit = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this)));
|
||||||
|
_comms.SetSiteCallAudit(siteCallAudit);
|
||||||
Services.AddSingleton(_comms);
|
Services.AddSingleton(_comms);
|
||||||
|
|
||||||
var aggregator = Substitute.For<ICentralHealthAggregator>();
|
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]
|
[Fact]
|
||||||
public void OutboxKpiFailure_ShowsGracefulFallback()
|
public void OutboxKpiFailure_ShowsGracefulFallback()
|
||||||
{
|
{
|
||||||
@@ -170,4 +228,16 @@ public class HealthPageTests : BunitContext
|
|||||||
Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply));
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user