0db253b55f
The Health dashboard's "Trigger failover" confirmation offered a red button
labelled *Delete*. The copy was right; the button was not. `DialogHost` derived
the confirm label solely from `danger`, so `danger: true` meant the verb was
literally "Delete" for every caller — and no caller could say otherwise, because
`IDialogService.ConfirmAsync` had no label parameter.
`danger` now selects styling only; a new optional `confirmLabel` names the action
and wins over the fallback. The kind/danger fallback is deliberately RETAINED
("Save" for a prompt, "Delete" for a bare danger confirm, "Confirm" otherwise):
around 20 call sites are genuine deletes where it is correct, and the Playwright
suites click the literal "Delete". Requiring the label everywhere would have
churned those 20 sites for no behaviour change, with the miss-detection resting
on Playwright runs that need a live rig.
Audited all 34 confirm call sites; 9 read "Delete" for something that is not a
delete and are relabelled:
Fail over CentralFailoverControl (central pair, site pair)
Approve SecuredWrites — approve (this one WRITES TO A DEVICE)
Reject SecuredWrites — reject
Disable Topology — disable instance
Discard SiteCallsReport, NotificationReport, ParkedMessages (bulk + single)
The remaining 25 are correct as they stand: 18 genuine deletes plus 7 non-danger
confirms ("Revert to base", the three Retry paths) whose button reads "Confirm".
Tests: FailoverConfirmLabelTests drives the real DialogService behind a real
DialogHost — a substitute would pin the argument but not what the operator reads
— covering both branches, that the button keeps btn-danger, and that an
unlabelled danger confirm still falls back to "Delete". Note its click is
deliberately not awaited: with a real dialog the handler parks on ConfirmAsync
until answered, so awaiting deadlocks. Existing IDialogService fakes and
NSubstitute matchers gain the 4th parameter (an optional param is baked into the
call site, so a 3-arg Arg.Any stub silently stops matching a labelled call).
696 lines
30 KiB
C#
696 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);
|
|
|
|
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);
|
|
}
|
|
}
|