Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs
T
Joseph Doherty 40944a0d04 feat(centralui): row 75 — Approve is caution amber, not destructive red
The Secured Writes page rendered Approve and Reject as IDENTICAL red buttons.
Row 28 fixed the labels but left the styling, and the styling was the worse
half: at the moment of a two-person-control decision the colour carried no
discriminating information at all, and the affirmative half of the control wore
the delete affordance.

Root cause is that `danger` was a two-state flag for a three-state concept, so a
consequential-but-not-destructive action had to choose between looking like a
delete and looking like the safe default. Adds the missing middle rung:

    DialogSeverity { Normal -> btn-primary, Caution -> btn-warning,
                     Destructive -> btn-danger }

DECISION. Approve -> Caution (amber): approving executes a real write to live
plant equipment, so it is not a benign primary action, but it is not destroying
anything either. Reject KEEPS Destructive (red): it is terminal and irreversible
for the request — the Operator must resubmit — and holding it red is what keeps
the two opposite decisions visually distinct now that Approve is amber. Dropping
Reject to neutral was considered and rejected: it would invert the bias, making
declining look like the safe default. Colour is never the only signal — both
buttons carry their verb.

Only Destructive inherits the row-28 "Delete" label fallback; an unlabelled
Caution reads "Confirm", since a caution action is by definition not a delete —
otherwise row 28 would come back through the new door.

ADDITIVE OVERLOAD, deliberately. The boolean form stays the signature for all
~34 existing call sites, which are genuinely two-state, so nothing in src/
outside SecuredWrites.razor changed. Adding a fifth optional parameter to the
boolean form instead would have silently un-matched every existing four-argument
NSubstitute stub — the exact row-28 gotcha (null Task, caller NREs, no compile
error). The overload cannot: the only breakage was six hand-written IDialogService
fakes failing to compile, which is the loud failure mode. That gotcha did land
once, in SecuredWritesTests' own stub, and is now stubbed on both overloads with
a comment saying why.

DialogState's `bool Danger` became `DialogSeverity Severity` rather than gaining
a second field — it is read only by DialogHost, and one field cannot disagree
with itself.

Fail-before, both halves:
  - Approve call site reverted to `danger: true` -> both new Approve pins fail.
  - Caution remapped to btn-danger in DialogHost -> the amber styling pin fails.

Tests: DialogSeverityStylingTests renders the REAL DialogService behind a real
DialogHost (FailoverConfirmLabelTests' harness), so it pins what the user sees,
not what an argument was — all three severities, both label fallbacks, and the
boolean adapter. SecuredWritesTests pins that the page asks for Caution, with a
negative control that it did NOT also go through the boolean form (without which
the assertion would pass on a call site left at danger:true). Reject's red is
pinned too, so a later "make this page less alarming" pass cannot flatten both
to one colour.

No Playwright selectors needed updating — the suite has no Secured Writes
coverage, and no `.btn-danger` selector elsewhere targets this page.

Docs: Component-CentralUI.md gains the severity contract under Technology (next
to the row-28 label contract) plus the decision on the Secured Writes section.

CentralUI.Tests: 1055/1055 green.
2026-08-15 21:33:08 -04:00

702 lines
30 KiB
C#

using System.Security.Claims;
using Akka.Actor;
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
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.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Security;
using SiteCallsReportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.SiteCalls.SiteCallsReport;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// bUnit rendering tests for the Site Calls report page (Site Call Audit #22).
///
/// Testability note: <see cref="CommunicationService"/> is a concrete class with
/// non-virtual methods, so NSubstitute cannot intercept it. The page's calls all
/// route through an injected <see cref="IActorRef"/> (the Site Call Audit proxy),
/// so the tests wire a real, lightweight <see cref="ActorSystem"/> with a scripted
/// <see cref="ReceiveActor"/> that replies with fixed responses — the same seam
/// <c>SetSiteCallAudit</c> exists for. Mirrors <see cref="NotificationReportPageTests"/>.
/// </summary>
public class SiteCallsReportPageTests : BunitContext
{
private readonly ActorSystem _system = ActorSystem.Create("site-calls-report-tests");
private readonly CommunicationService _comms;
// K14: KPI-history facade for the Trends section. Defaults to a known
// non-empty (≥2-point) series so an expanded chart draws a polyline;
// individual tests reconfigure it (e.g. to throw) before rendering.
private readonly IKpiHistoryQueryService _kpiHistory = Substitute.For<IKpiHistoryQueryService>();
private static readonly Guid ParkedId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly Guid FailedId = Guid.Parse("22222222-2222-2222-2222-222222222222");
// Mutable scripted reply — individual tests can override before rendering.
private SiteCallQueryResponse _queryReply = new(
"q", true, null,
new List<SiteCallSummary>
{
new(ParkedId, "plant-a", "ApiOutbound", "ERP.GetOrder", "Parked",
RetryCount: 3, LastError: "HTTP 503 from ERP", HttpStatus: 503,
CreatedAtUtc: DateTime.UtcNow.AddMinutes(-30), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-5),
TerminalAtUtc: null, IsStuck: true),
new(FailedId, "plant-b", "DbOutbound", "Historian.Write", "Failed",
RetryCount: 1, LastError: "constraint violation", HttpStatus: null,
CreatedAtUtc: DateTime.UtcNow.AddHours(-2), UpdatedAtUtc: DateTime.UtcNow.AddHours(-2),
TerminalAtUtc: DateTime.UtcNow.AddHours(-2), IsStuck: false),
},
NextAfterCreatedAtUtc: null,
NextAfterId: null);
// Records the most recent retry/discard requests the actor received.
private readonly List<SiteCallQueryRequest> _queryRequests = new();
private readonly List<RetrySiteCallRequest> _retryRequests = new();
private readonly List<DiscardSiteCallRequest> _discardRequests = new();
// Scripted relay responses — overridable per test.
private RetrySiteCallResponse _retryReply =
new("q", SiteCallRelayOutcome.Applied, true, true, null);
private DiscardSiteCallResponse _discardReply =
new("q", SiteCallRelayOutcome.Applied, true, true, null);
public SiteCallsReportPageTests()
{
_comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
var auditProxy = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this)));
_comms.SetSiteCallAudit(auditProxy);
Services.AddSingleton(_comms);
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
// K14: by default every trend series resolves to a known ≥2-point series so
// an expanded chart draws a polyline. Tests that need a failure reconfigure
// this substitute before rendering.
_kpiHistory.GetSeriesAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(new List<KpiSeriesPoint>
{
new(DateTime.UtcNow.AddHours(-2), 3),
new(DateTime.UtcNow.AddHours(-1), 7),
new(DateTime.UtcNow, 5),
}));
Services.AddSingleton(_kpiHistory);
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
{
new("Plant A", "plant-a") { Id = 1 },
new("Plant B", "plant-b") { Id = 2 },
}));
Services.AddSingleton(siteRepo);
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Deployer"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
}
[Fact]
public void Page_RequiresDeploymentPolicy()
{
var attr = typeof(SiteCallsReportPage)
.GetCustomAttributes(typeof(AuthorizeAttribute), true)
.Cast<AuthorizeAttribute>()
.FirstOrDefault();
Assert.NotNull(attr);
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
}
[Fact]
public void Renders_SiteCallRows()
{
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
Assert.Contains("ERP.GetOrder", cut.Markup);
Assert.Contains("Historian.Write", cut.Markup);
});
}
[Fact]
public void StuckRow_IsBadged()
{
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
var stuckRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("ERP.GetOrder"));
Assert.Contains("badge", stuckRow.InnerHtml);
Assert.Contains("Stuck", stuckRow.TextContent);
});
}
[Fact]
public void RetryDiscardButtons_ShownOnlyOnParkedRows()
{
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
var parkedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("ERP.GetOrder"));
var failedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Historian.Write"));
// The Parked row carries Retry + Discard buttons.
Assert.Contains(parkedRow.QuerySelectorAll("button"),
b => b.TextContent.Contains("Retry"));
Assert.Contains(parkedRow.QuerySelectorAll("button"),
b => b.TextContent.Contains("Discard"));
// The Failed row carries neither — Retry/Discard are Parked-only.
Assert.DoesNotContain(failedRow.QuerySelectorAll("button"),
b => b.TextContent.Contains("Retry"));
Assert.DoesNotContain(failedRow.QuerySelectorAll("button"),
b => b.TextContent.Contains("Discard"));
}
[Fact]
public void ClickRetry_OnParkedRow_RelaysRetryToOwningSite()
{
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
var parkedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("ERP.GetOrder"));
var retryButton = parkedRow.QuerySelectorAll("button")
.First(b => b.TextContent.Contains("Retry"));
retryButton.Click();
cut.WaitForAssertion(() =>
{
Assert.Single(_retryRequests);
Assert.Equal(ParkedId, _retryRequests[0].TrackedOperationId);
// The relay carries the owning site so central can route it.
Assert.Equal("plant-a", _retryRequests[0].SourceSite);
});
}
[Fact]
public void ClickDiscard_OnParkedRow_RelaysDiscardToOwningSite()
{
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
var parkedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("ERP.GetOrder"));
var discardButton = parkedRow.QuerySelectorAll("button")
.First(b => b.TextContent.Contains("Discard"));
discardButton.Click();
cut.WaitForAssertion(() =>
{
Assert.Single(_discardRequests);
Assert.Equal(ParkedId, _discardRequests[0].TrackedOperationId);
Assert.Equal("plant-a", _discardRequests[0].SourceSite);
});
}
[Fact]
public void RetryRelay_SiteUnreachable_ShowsDistinctMessage()
{
// The relay never reached the owning site — a transient transport
// condition, surfaced distinctly from a generic failure.
_retryReply = new RetrySiteCallResponse(
"q", SiteCallRelayOutcome.SiteUnreachable, Success: false, SiteReachable: false,
ErrorMessage: "Site plant-a is offline — relay not delivered.");
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
var parkedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("ERP.GetOrder"));
parkedRow.QuerySelectorAll("button")
.First(b => b.TextContent.Contains("Retry"))
.Click();
cut.WaitForAssertion(() =>
Assert.Contains("offline", cut.Markup));
}
[Fact]
public void QueryFailure_ShowsErrorMessage()
{
_queryReply = new SiteCallQueryResponse(
"q", false, "site call query backend unavailable",
new List<SiteCallSummary>(), null, null);
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
Assert.Contains("site call query backend unavailable", cut.Markup));
}
// ─────────────────────────────────────────────────────────────────────────
// Drill-in — every row carries a "View audit history" link to
// /audit/log?correlationId={TrackedOperationId}.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void SiteCallRow_ViewAuditHistory_Link_HasCorrectHref()
{
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
// Both rows (Parked + Failed) surface the link — the drill-in is
// row-scope, not status-scope.
var parkedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("ERP.GetOrder"));
var link = parkedRow.QuerySelector("a[data-test^=\"audit-link-\"]");
Assert.NotNull(link);
Assert.Equal(
$"/audit/log?correlationId={ParkedId}",
link!.GetAttribute("href"));
Assert.Contains("View audit history", link.TextContent);
var failedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Historian.Write"));
var failedLink = failedRow.QuerySelector("a[data-test^=\"audit-link-\"]");
Assert.NotNull(failedLink);
Assert.Equal(
$"/audit/log?correlationId={FailedId}",
failedLink!.GetAttribute("href"));
});
}
// ─────────────────────────────────────────────────────────────────────────
// Keyset paging — Next is driven by the response's NextAfter* cursor, not by
// page numbers; the request echoes the cursor back to the actor.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void Paging_NextButton_HiddenWhenNoFurtherPage()
{
// The default reply returns 2 rows and no NextAfter* cursor — there is no
// further page, so Next is disabled.
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
var next = cut.Find("[data-test='keyset-next']");
Assert.True(next.HasAttribute("disabled"));
var prev = cut.Find("[data-test='keyset-prev']");
Assert.True(prev.HasAttribute("disabled"));
}
[Fact]
public void Paging_NextButton_AdvancesUsingKeysetCursor()
{
// A full page (PageSize=50 rows) plus a NextAfter* cursor: Next is live
// and, when clicked, the follow-up query carries that cursor.
var firstPage = new List<SiteCallSummary>();
for (var i = 0; i < 50; i++)
{
firstPage.Add(new SiteCallSummary(
Guid.NewGuid(), "plant-a", "ApiOutbound", $"ERP.Op{i}", "Delivered",
RetryCount: 0, LastError: null, HttpStatus: 200,
CreatedAtUtc: DateTime.UtcNow.AddMinutes(-i), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-i),
TerminalAtUtc: DateTime.UtcNow.AddMinutes(-i), IsStuck: false));
}
var cursorCreated = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
var cursorId = Guid.Parse("99999999-9999-9999-9999-999999999999");
_queryReply = new SiteCallQueryResponse(
"q", true, null, firstPage,
NextAfterCreatedAtUtc: cursorCreated,
NextAfterId: cursorId);
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.Op0"));
var next = cut.Find("[data-test='keyset-next']");
Assert.False(next.HasAttribute("disabled"));
next.Click();
cut.WaitForAssertion(() =>
{
// Two queries fired: the initial load and the Next click. The second
// carries the keyset cursor echoed by the first response.
Assert.Equal(2, _queryRequests.Count);
Assert.Equal(cursorCreated, _queryRequests[1].AfterCreatedAtUtc);
Assert.Equal(cursorId, _queryRequests[1].AfterId);
});
}
[Fact]
public void Paging_PrevButton_PopsBackStackAndRefetchesPriorCursor()
{
// The keyset back-stack is the trickiest paging path: Next pushes the
// current cursor, Prev pops it and refetches that prior page. Page 1 is
// opened with the empty (null, null) cursor, so after Next→Previous the
// follow-up query must carry (null, null) again.
var firstPage = new List<SiteCallSummary>();
for (var i = 0; i < 50; i++)
{
firstPage.Add(new SiteCallSummary(
Guid.NewGuid(), "plant-a", "ApiOutbound", $"ERP.Op{i}", "Delivered",
RetryCount: 0, LastError: null, HttpStatus: 200,
CreatedAtUtc: DateTime.UtcNow.AddMinutes(-i), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-i),
TerminalAtUtc: DateTime.UtcNow.AddMinutes(-i), IsStuck: false));
}
var cursorCreated = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
var cursorId = Guid.Parse("99999999-9999-9999-9999-999999999999");
_queryReply = new SiteCallQueryResponse(
"q", true, null, firstPage,
NextAfterCreatedAtUtc: cursorCreated,
NextAfterId: cursorId);
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.Op0"));
// Step forward — query 2 carries the keyset cursor.
var next = cut.Find("[data-test='keyset-next']");
next.Click();
cut.WaitForAssertion(() =>
{
Assert.Equal(2, _queryRequests.Count);
Assert.Equal(cursorCreated, _queryRequests[1].AfterCreatedAtUtc);
});
// Previous is now live (the back-stack has one entry); click it.
var prev = cut.Find("[data-test='keyset-prev']");
Assert.False(prev.HasAttribute("disabled"));
prev.Click();
cut.WaitForAssertion(() =>
{
// Query 3 is the Previous refetch — the back-stack popped the page-1
// cursor, which is the empty (null, null) first-page cursor.
Assert.Equal(3, _queryRequests.Count);
Assert.Null(_queryRequests[2].AfterCreatedAtUtc);
Assert.Null(_queryRequests[2].AfterId);
// Back on page 1, the back-stack is empty again so Previous re-disables.
Assert.True(cut.Find("[data-test='keyset-prev']").HasAttribute("disabled"));
});
}
[Fact]
public void RetryRelay_NotParked_ShowsInfoMessage_AndExactlyOneToast()
{
// NotParked is a definitive answer from the site (nothing to do), not a
// failure — it surfaces as a single info toast, never an error. This
// also guards the single-toast contract: a non-Applied outcome must
// produce exactly one toast.
_retryReply = new RetrySiteCallResponse(
"q", SiteCallRelayOutcome.NotParked, Success: false, SiteReachable: true,
ErrorMessage: "The cached call is no longer parked.");
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
var parkedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("ERP.GetOrder"));
parkedRow.QuerySelectorAll("button")
.First(b => b.TextContent.Contains("Retry"))
.Click();
cut.WaitForAssertion(() =>
{
Assert.Contains("no longer parked", cut.Markup);
// Exactly one toast — the ShowRelayOutcome switch owns the single
// toast; no second (error) toast piggybacks on the same response.
Assert.Single(cut.FindAll(".toast"));
});
}
// ─────────────────────────────────────────────────────────────────────────
// Query-string drill-in — the Health-dashboard Site Call KPI tiles deep-link
// here with ?status=Parked (Parked tile) and ?stuck=true (Stuck tile). The
// params must seed the filter BEFORE the first query so the initial grid load
// is already filtered, and the filter card controls must reflect the values.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void NavigateWithStatusParkedParam_LoadsGridPreFilteredToParked()
{
// The Parked KPI tile emits ?status=Parked — set the URI before render.
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/site-calls/report?status=Parked");
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
// The first (and only) query the page issues carries the Parked
// status filter — the grid load is pre-filtered, not unfiltered.
Assert.Single(_queryRequests);
Assert.Equal("Parked", _queryRequests[0].StatusFilter);
// The Status <select> control reflects the seeded value so the
// operator sees the filter and can Clear it.
var statusSelect = cut.Find("#sc-status");
Assert.Equal("Parked", statusSelect.GetAttribute("value"));
});
}
[Fact]
public void NavigateWithStuckTrueParam_LoadsGridWithStuckFilterApplied()
{
// The Stuck KPI tile emits ?stuck=true.
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/site-calls/report?stuck=true");
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
// The first query carries StuckOnly = true.
Assert.Single(_queryRequests);
Assert.True(_queryRequests[0].StuckOnly);
// The "Stuck only" checkbox is checked.
var stuckCheckbox = cut.Find("#sc-stuck-only");
Assert.True(stuckCheckbox.HasAttribute("checked"));
});
}
[Fact]
public void NavigateWithNoQueryParams_LoadsGridUnfiltered()
{
// No drill-in params — the page loads exactly as before: an unfiltered
// query and no status/stuck filter set on the controls.
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
Assert.Single(_queryRequests);
Assert.Null(_queryRequests[0].StatusFilter);
Assert.False(_queryRequests[0].StuckOnly);
var statusSelect = cut.Find("#sc-status");
Assert.True(string.IsNullOrEmpty(statusSelect.GetAttribute("value")));
var stuckCheckbox = cut.Find("#sc-stuck-only");
Assert.False(stuckCheckbox.HasAttribute("checked"));
});
}
[Fact]
public void SiteScoping_ScopedDeploymentUser_HidesOutOfScopeRows()
{
// CentralUI-028: a Deployment user scoped to Plant A only must not see
// Plant B rows in the grid, even though the query response carried both.
// Last AuthenticationStateProvider registration wins on resolution.
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(JwtTokenService.UsernameClaimType, "scoped"),
new Claim(JwtTokenService.RoleClaimType, "Deployer"),
new Claim(JwtTokenService.SiteIdClaimType, "1"), // Plant A only
}, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(scopedUser));
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.FindAll("table tbody tr").Count > 0,
TimeSpan.FromSeconds(2));
var rows = cut.FindAll("table tbody tr");
Assert.Single(rows);
// Plant A row only; Plant B (FailedId) row must be filtered out.
Assert.Contains(ParkedId.ToString("N")[..12], rows[0].TextContent);
Assert.DoesNotContain(FailedId.ToString("N")[..12], cut.Markup);
}
// ─────────────────────────────────────────────────────────────────────────
// Trends (K14) — a collapsible section with one KpiTrendChart per metric
// (buffered / parked / failedLastInterval) for SiteCallAudit / Global. The
// section is collapsed on init; expanding it lazy-loads the series.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void TrendsSection_Container_AlwaysRenders()
{
var cut = Render<SiteCallsReportPage>();
// The collapsible container (with its toggle) is present even while
// collapsed — only the charts are gated behind the expand.
cut.WaitForAssertion(() =>
Assert.NotNull(cut.Find("[data-test='site-calls-trends']")));
}
[Fact]
public void TrendsSection_WhenExpanded_RendersChartsFromKnownSeries()
{
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
// Collapsed on init — no chart yet.
Assert.Empty(cut.FindAll("[data-test^='kpi-trend-']"));
// Expand the Trends section; the lazy series load fills the charts.
cut.Find("[data-test='site-calls-trends-toggle']").Click();
cut.WaitForAssertion(() =>
{
// At least one trend chart rendered, and (because the series has ≥2
// points) it draws a polyline rather than the unavailable placeholder.
var charts = cut.FindAll("[data-test^='kpi-trend-']");
Assert.NotEmpty(charts);
Assert.Contains("<polyline", cut.Markup);
});
}
[Fact]
public void TrendsSection_WindowToggle_ReloadsSeries()
{
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
cut.Find("[data-test='site-calls-trends-toggle']").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll("[data-test^='kpi-trend-']")));
// First expand issued 3 GetSeriesAsync calls (one per metric).
_kpiHistory.ReceivedCalls();
var afterExpand = _kpiHistory.ReceivedCalls().Count();
// Switch the window to 7d — the series re-load (another 3 calls).
cut.FindAll("button").First(b => b.TextContent.Trim() == "7d").Click();
cut.WaitForAssertion(() =>
Assert.True(_kpiHistory.ReceivedCalls().Count() > afterExpand));
}
[Fact]
public void TrendsSection_LongRangeWindows_RenderAndReloadSeries()
{
// The 30d/90d buttons (plan #22 T7) render alongside 24h/7d and clicking
// one re-loads the series — the query service then routes the long window
// to hourly rollups transparently.
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
cut.Find("[data-test='site-calls-trends-toggle']").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll("[data-test^='kpi-trend-']")));
// Both long-range buttons are present in the toggle group.
Assert.Contains(cut.FindAll("button"), b => b.TextContent.Trim() == "30d");
Assert.Contains(cut.FindAll("button"), b => b.TextContent.Trim() == "90d");
var afterExpand = _kpiHistory.ReceivedCalls().Count();
// Switch the window to 90d — the series re-load (another batch of calls).
cut.FindAll("button").First(b => b.TextContent.Trim() == "90d").Click();
cut.WaitForAssertion(() =>
Assert.True(_kpiHistory.ReceivedCalls().Count() > afterExpand));
}
[Fact]
public void TrendsSection_WhenKpiHistoryThrows_PageStillRenders()
{
// Best-effort contract: a GetSeriesAsync throw must never break the page.
_kpiHistory.GetSeriesAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns<Task<IReadOnlyList<KpiSeriesPoint>>>(_ =>
throw new InvalidOperationException("KPI backend down"));
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
// Expanding triggers the failing loads — the grid and trends container
// both survive; the charts fall back to the unavailable placeholder.
cut.Find("[data-test='site-calls-trends-toggle']").Click();
cut.WaitForAssertion(() =>
{
Assert.Contains("ERP.GetOrder", cut.Markup);
Assert.NotNull(cut.Find("[data-test='site-calls-trends']"));
// No polyline — the charts degraded to the unavailable em-dash state.
Assert.DoesNotContain("<polyline", cut.Markup);
});
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_system.Terminate().Wait(TimeSpan.FromSeconds(5));
}
base.Dispose(disposing);
}
/// <summary>
/// Stand-in for the Site Call Audit actor. Replies to each message type with
/// the test's currently-scripted response.
/// </summary>
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
{
public ScriptedSiteCallAuditActor(SiteCallsReportPageTests test)
{
Receive<SiteCallQueryRequest>(r =>
{
test._queryRequests.Add(r);
Sender.Tell(test._queryReply);
});
Receive<RetrySiteCallRequest>(r =>
{
test._retryRequests.Add(r);
Sender.Tell(test._retryReply);
});
Receive<DiscardSiteCallRequest>(r =>
{
test._discardRequests.Add(r);
Sender.Tell(test._discardReply);
});
}
}
/// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
private sealed class AlwaysConfirmDialogService : IDialogService
{
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null)
=> Task.FromResult(true);
// Register row 75 added a DialogSeverity overload. This stub auto-confirms
// regardless of severity — it exists to let action paths run end to end, not to
// assert anything about styling.
public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null)
=> Task.FromResult(true);
public Task<string?> PromptAsync(
string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
public Task<TResult?> ShowAsync<TResult>(
string title,
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
string? size = null)
=> Task.FromResult<TResult?>(default);
}
}