feat(centralui): server-side paging + server-computed status counts on Deployments (R3)

The Deployment Status page client-materialized the whole deployment list. It read
EVERY DeploymentRecord — an insert-only table, one row per deploy attempt for the
retention window — plus EVERY Instance, then site-scoped, sorted, counted the four
status tiles and sliced a 25-row page in the Blazor circuit's memory. That ran on
first render AND on every IDeploymentStatusNotifier push, so the cost scaled with
the age of the system rather than the size of the page.

All four jobs move into SQL:

- `IDeploymentManagerRepository.QueryDeploymentListPageAsync(filter, page, size)`
  returns one page of `DeploymentListRow` — DeploymentRecord INNER JOINed to
  Instance, so the instance display name and site travel with the rows that need
  them — plus the total count of the filtered set. The join is exact: the FK is
  Restrict and DeleteInstanceAsync removes the records first, so no orphan exists.
- `GetDeploymentStatusCountsAsync(filter)` returns the tile counts from ONE grouped
  aggregation, deliberately ignoring the filter's Status: the tiles are the status
  BREAKDOWN of the filtered set, so honouring it would zero three of four tiles the
  moment an operator clicked one.
- Site scoping runs in the query as `SiteIdScope` resolved through the record's
  instance (DeploymentRecord has no SiteId of its own). An EMPTY grant stays a real
  filter matching nothing, never "unconstrained".
- The now-callerless whole-table `GetAllDeploymentRecordsAsync` is deleted.

OFFSET paging, not the Audit Log's keyset cursor, and deliberately so: this page's
pager is numbered and jump-to-any-page, so it needs a page count, which only a total
can give it — a keyset cursor can express neither, and the total is required for the
tiles regardless. The deep-offset cost that pushes high-volume tables to keyset is
bounded here by the terminal-record retention purge, unlike the 365-day AuditLog.
This mirrors the Notification Outbox, offset-paged for the same reason. Ordering is
DeployedAt DESC, Id DESC — the Id tie-break is load-bearing, because DeployedAt ties
on rapid redeploys and an unstable sort key makes offset paging repeat or drop rows.

UI: the four status tiles become the status filter (click to apply, click again to
clear, aria-pressed, phrasing-only content so a <button> stays valid), plus a
free-text search matched DB-side against instance name, deployment id, revision hash
and initiating user. Search is TRAILING-edge debounced at 500ms — the same Timer +
lock + _disposed idiom as the existing leading-edge push coalescer, minus the leading
edge, because a search box must not query on the first keystroke. A filter change
resets to page 1; a page past the end falls back to the last real page. Bootstrap
only, existing PagerWindow pager retained.

The WP2.4 push coalescing is unchanged and still earns its keep: server paging shrank
what a reload costs, not how many arrive — it now bounds database round-trips rather
than table scans.

Tests: 19 new SQLite repository tests (paging slice + total, tie-break stability
across pages, page/size clamping, past-the-end, the joined projection, every filter
dimension incl. the empty-scope security case, and the grouped counts' status-blind
contract); 15 new bUnit page tests (page-1 request, server total drives the pager,
Next re-queries, tiles show server counts not page counts, tile filter + toggle,
system-wide vs site-scoped scope push, debounce collapses a keystroke burst to one
query, clear-filters, dispose with an armed timer). The two existing Deployments
suites re-point their reload assertions at the new query.

Doc: Component-CentralUI.md Deployment section — the "no server-side paging" known
residual is replaced by the shipped design.
This commit is contained in:
Joseph Doherty
2026-08-15 03:31:28 -04:00
parent 2b74851f96
commit 35ce14138c
12 changed files with 1436 additions and 139 deletions
@@ -6,9 +6,8 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using DeploymentsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.Deployments;
@@ -23,29 +22,32 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// the timer and subscribes to <see cref="IDeploymentStatusNotifier"/>, which
/// <c>DeploymentService</c> raises on every deployment-record status write;
/// Blazor Server then pushes the re-render over its SignalR circuit.
///
/// <para>
/// The "did it reload?" assertions moved from <c>GetAllDeploymentRecordsAsync</c>
/// to <c>QueryDeploymentListPageAsync</c> when the page's read path went
/// server-paged (residual R3) — the push mechanism under test is unchanged, only
/// the query it drives.
/// </para>
/// </summary>
public class DeploymentsPushUpdateTests : BunitContext
{
private IDeploymentManagerRepository _deployRepo = null!;
private ITemplateEngineRepository _templateRepo = null!;
private DeploymentStatusNotifier _notifier = null!;
private void RegisterServices()
{
_deployRepo = Substitute.For<IDeploymentManagerRepository>();
_templateRepo = Substitute.For<ITemplateEngineRepository>();
_notifier = new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance);
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Instance>
{
new("Inst-1") { Id = 1, SiteId = 1 }
});
_deployRepo.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>())
.Returns(new List<DeploymentRecord>());
_deployRepo.QueryDeploymentListPageAsync(
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(DeploymentListPage.Empty);
_deployRepo.GetDeploymentStatusCountsAsync(
Arg.Any<DeploymentListFilter>(), Arg.Any<CancellationToken>())
.Returns(DeploymentStatusCounts.Empty);
Services.AddSingleton(_deployRepo);
Services.AddSingleton(_templateRepo);
Services.AddSingleton<IDeploymentStatusNotifier>(_notifier);
var identity = new ClaimsIdentity(
@@ -80,9 +82,8 @@ public class DeploymentsPushUpdateTests : BunitContext
RegisterServices();
var cut = Render<DeploymentsPage>();
// Initial load: instances + records each fetched once.
// Initial load: the paged query is issued once.
_deployRepo.ClearReceivedCalls();
_templateRepo.ClearReceivedCalls();
// A deployment status write in DeploymentManager raises the notifier;
// the page must reload in response (no polling timer involved).
@@ -90,7 +91,8 @@ public class DeploymentsPushUpdateTests : BunitContext
new DeploymentStatusChange("dep-1", 1, DeploymentStatus.Success));
cut.WaitForAssertion(() =>
_deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>()));
_deployRepo.Received().QueryDeploymentListPageAsync(
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>()));
}
[Fact]
@@ -106,8 +108,8 @@ public class DeploymentsPushUpdateTests : BunitContext
_notifier.NotifyStatusChanged(
new DeploymentStatusChange("dep-2", 1, DeploymentStatus.Failed));
_deployRepo.DidNotReceive()
.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>());
_deployRepo.DidNotReceive().QueryDeploymentListPageAsync(
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>());
}
/// <summary>
@@ -150,7 +152,7 @@ public class DeploymentsPushUpdateTests : BunitContext
Assert.Null(ex);
// The guard short-circuits before any reload is attempted.
_deployRepo.DidNotReceive()
.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>());
_deployRepo.DidNotReceive().QueryDeploymentListPageAsync(
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>());
}
}
@@ -5,9 +5,8 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using DeploymentsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.Deployments;
@@ -15,31 +14,36 @@ using DeploymentsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploym
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Regression tests for the Deployment Status push coalescing (arch-review WP2.4). One
/// notifier callback reloads EVERY deployment record plus EVERY instance, and the notifier
/// fires per status write — so a multi-instance deploy produced a stampede of full reloads
/// per circuit. The reload is now leading-edge debounced: the first push after an idle gap
/// is still immediate, and a burst behind it collapses into one trailing reload.
/// Regression tests for the Deployment Status push coalescing (arch-review WP2.4). The
/// notifier fires per status write, so a multi-instance deploy produced a stampede of
/// reloads per circuit. The reload is leading-edge debounced: the first push after an
/// idle gap is still immediate, and a burst behind it collapses into one trailing reload.
///
/// <para>
/// Server-side paging (residual R3) shrank what one reload costs but not how many
/// arrive, so the coalescing still has to hold. The assertions now count
/// <c>QueryDeploymentListPageAsync</c> calls instead of the deleted
/// <c>GetAllDeploymentRecordsAsync</c>.
/// </para>
/// </summary>
public class DeploymentsReloadDebounceTests : BunitContext
{
private IDeploymentManagerRepository _deployRepo = null!;
private ITemplateEngineRepository _templateRepo = null!;
private DeploymentStatusNotifier _notifier = null!;
private void RegisterServices()
{
_deployRepo = Substitute.For<IDeploymentManagerRepository>();
_templateRepo = Substitute.For<ITemplateEngineRepository>();
_notifier = new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance);
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Instance> { new("Inst-1") { Id = 1, SiteId = 1 } });
_deployRepo.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>())
.Returns(new List<DeploymentRecord>());
_deployRepo.QueryDeploymentListPageAsync(
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(DeploymentListPage.Empty);
_deployRepo.GetDeploymentStatusCountsAsync(
Arg.Any<DeploymentListFilter>(), Arg.Any<CancellationToken>())
.Returns(DeploymentStatusCounts.Empty);
Services.AddSingleton(_deployRepo);
Services.AddSingleton(_templateRepo);
Services.AddSingleton<IDeploymentStatusNotifier>(_notifier);
var identity = new ClaimsIdentity(
@@ -58,13 +62,17 @@ public class DeploymentsReloadDebounceTests : BunitContext
=> Task.FromResult(_state);
}
private void AssertReloaded(IRenderedComponent<DeploymentsPage> cut) =>
cut.WaitForAssertion(() =>
_deployRepo.Received().QueryDeploymentListPageAsync(
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>()));
[Fact]
public void BurstOfStatusWrites_CollapsesIntoFarFewerReloads()
{
RegisterServices();
var cut = Render<DeploymentsPage>();
cut.WaitForAssertion(() =>
_deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>()));
AssertReloaded(cut);
_deployRepo.ClearReceivedCalls();
// A 40-instance site deploy: every status write raises the notifier.
@@ -75,14 +83,13 @@ public class DeploymentsReloadDebounceTests : BunitContext
}
// Leading edge fires at once; the rest ride one trailing reload.
cut.WaitForAssertion(() =>
_deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>()));
AssertReloaded(cut);
// Let the trailing window close before counting.
Thread.Sleep(900);
var reloads = _deployRepo.ReceivedCalls()
.Count(c => c.GetMethodInfo().Name == nameof(IDeploymentManagerRepository.GetAllDeploymentRecordsAsync));
.Count(c => c.GetMethodInfo().Name == nameof(IDeploymentManagerRepository.QueryDeploymentListPageAsync));
Assert.InRange(reloads, 1, 4);
}
@@ -91,8 +98,7 @@ public class DeploymentsReloadDebounceTests : BunitContext
{
RegisterServices();
var cut = Render<DeploymentsPage>();
cut.WaitForAssertion(() =>
_deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>()));
AssertReloaded(cut);
_deployRepo.ClearReceivedCalls();
// Idle since the initial load — the leading edge must not wait out the window.
@@ -101,7 +107,8 @@ public class DeploymentsReloadDebounceTests : BunitContext
new DeploymentStatusChange("dep-1", 1, DeploymentStatus.Success));
cut.WaitForAssertion(
() => _deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>()),
() => _deployRepo.Received().QueryDeploymentListPageAsync(
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>()),
TimeSpan.FromMilliseconds(400));
}
@@ -110,8 +117,7 @@ public class DeploymentsReloadDebounceTests : BunitContext
{
RegisterServices();
var cut = Render<DeploymentsPage>();
cut.WaitForAssertion(() =>
_deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>()));
AssertReloaded(cut);
// Two pushes: the first takes the leading edge, the second arms the trailing timer.
_notifier.NotifyStatusChanged(new DeploymentStatusChange("dep-1", 1, DeploymentStatus.InProgress));
@@ -122,6 +128,7 @@ public class DeploymentsReloadDebounceTests : BunitContext
// The armed timer must be disposed with the component, not fire against it.
Thread.Sleep(900);
_deployRepo.DidNotReceive().GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>());
_deployRepo.DidNotReceive().QueryDeploymentListPageAsync(
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,323 @@
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.Security;
using DeploymentsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.Deployments;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Coverage for the Deployment Status page's server-side paging, server-computed
/// status tiles and debounced filter input (residual R3).
///
/// <para>
/// Before this change the page read EVERY deployment record and EVERY instance on
/// each load, then site-scoped, sorted, counted the four tiles and sliced a 25-row
/// page in the Blazor circuit's memory. These tests pin the replacement contract:
/// the page asks the repository for ONE page plus the filtered total, asks for the
/// tile counts separately, pushes the site-scope grant into the query rather than
/// intersecting in memory, and never touches the instance repository.
/// </para>
/// </summary>
public class DeploymentsServerPagingTests : BunitContext
{
private IDeploymentManagerRepository _deployRepo = null!;
private ITemplateEngineRepository _templateRepo = null!;
private DeploymentStatusNotifier _notifier = null!;
/// <summary>Records every filter/page/pageSize triple the page queried with.</summary>
private readonly List<(DeploymentListFilter Filter, int Page, int PageSize)> _pageCalls = new();
private void RegisterServices(
DeploymentListPage? page = null,
DeploymentStatusCounts? counts = null,
string[]? siteClaims = null)
{
_deployRepo = Substitute.For<IDeploymentManagerRepository>();
_templateRepo = Substitute.For<ITemplateEngineRepository>();
_notifier = new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance);
var resultPage = page ?? DeploymentListPage.Empty;
_deployRepo.QueryDeploymentListPageAsync(
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
_pageCalls.Add((ci.ArgAt<DeploymentListFilter>(0), ci.ArgAt<int>(1), ci.ArgAt<int>(2)));
return Task.FromResult(resultPage);
});
_deployRepo.GetDeploymentStatusCountsAsync(
Arg.Any<DeploymentListFilter>(), Arg.Any<CancellationToken>())
.Returns(counts ?? DeploymentStatusCounts.Empty);
Services.AddSingleton(_deployRepo);
Services.AddSingleton(_templateRepo);
Services.AddSingleton<IDeploymentStatusNotifier>(_notifier);
var claims = new List<Claim> { new(ClaimTypes.Name, "deployer") };
foreach (var siteId in siteClaims ?? Array.Empty<string>())
{
claims.Add(new Claim(JwtTokenService.SiteIdClaimType, siteId));
}
var identity = new ClaimsIdentity(claims, "TestCookie");
var stubAuth = new StubAuthStateProvider(
new AuthenticationState(new ClaimsPrincipal(identity)));
Services.AddSingleton<AuthenticationStateProvider>(stubAuth);
Services.AddScoped(_ => new SiteScopeService(stubAuth));
}
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
public StubAuthStateProvider(AuthenticationState state) => _state = state;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
private static DeploymentListRow Row(int id, string instance, DeploymentStatus status = DeploymentStatus.Success) =>
new(
Id: id,
DeploymentId: $"dep-{id:D4}",
InstanceId: id,
InstanceUniqueName: instance,
SiteId: 1,
Status: status,
RevisionHash: $"rev{id:D4}hash",
DeployedBy: "deployer",
DeployedAt: DateTimeOffset.UtcNow.AddMinutes(-id),
CompletedAt: DateTimeOffset.UtcNow,
ErrorMessage: null);
// ── Paging ────────────────────────────────────────────────────────────────
[Fact]
public void InitialLoad_RequestsPageOneFromTheServer_AndNeverReadsEveryInstance()
{
RegisterServices(new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 1));
Render<DeploymentsPage>();
var call = Assert.Single(_pageCalls);
Assert.Equal(1, call.Page);
Assert.Equal(25, call.PageSize);
// The full-instance-table read that used to build the name/site maps is gone.
_templateRepo.DidNotReceiveWithAnyArgs().GetAllInstancesAsync();
}
[Fact]
public void TotalCountFromServer_DrivesThePager_NotTheRowsOnScreen()
{
// One page of 25 rows out of 130 total => 6 pages. Under the old
// client-materialized model the pager could only ever see the rows it held.
var rows = Enumerable.Range(1, 25).Select(i => Row(i, $"Inst-{i}")).ToList();
RegisterServices(new DeploymentListPage(rows, TotalCount: 130));
var cut = Render<DeploymentsPage>();
Assert.Contains("130 matching deployments", cut.Find("[data-test=\"deploy-result-count\"]").TextContent);
// Windowed pager: first, last and a radius around the current page.
var pageButtons = cut.FindAll("ul.pagination li.page-item").Count;
Assert.True(pageButtons > 2, "Pager must render page buttons, not just Previous/Next.");
Assert.Contains("6", cut.Find("ul.pagination").TextContent);
}
[Fact]
public void NextPage_RequeriesTheServerForPageTwo()
{
var rows = Enumerable.Range(1, 25).Select(i => Row(i, $"Inst-{i}")).ToList();
RegisterServices(new DeploymentListPage(rows, TotalCount: 130));
var cut = Render<DeploymentsPage>();
_pageCalls.Clear();
cut.Find("[data-test=\"deploy-page-next\"]").Click();
var call = Assert.Single(_pageCalls);
Assert.Equal(2, call.Page);
}
[Fact]
public void SinglePageResult_RendersNoPager()
{
RegisterServices(new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 1));
var cut = Render<DeploymentsPage>();
Assert.Empty(cut.FindAll("ul.pagination"));
}
// ── Status tiles ──────────────────────────────────────────────────────────
[Fact]
public void Tiles_RenderServerComputedCounts_NotCountsOfTheVisiblePage()
{
// The page holds ONE row, but the server reports fleet-wide counts. Under
// the old model the tiles were Count() over the in-memory list, so paging
// would have silently reduced them to per-page counts.
RegisterServices(
new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 400),
new DeploymentStatusCounts(Pending: 7, InProgress: 3, Success: 380, Failed: 10));
var cut = Render<DeploymentsPage>();
Assert.Contains("7", cut.Find("[data-test=\"deploy-tile-pending\"]").TextContent);
Assert.Contains("3", cut.Find("[data-test=\"deploy-tile-inprogress\"]").TextContent);
Assert.Contains("380", cut.Find("[data-test=\"deploy-tile-success\"]").TextContent);
Assert.Contains("10", cut.Find("[data-test=\"deploy-tile-failed\"]").TextContent);
Assert.Contains("400", cut.Find("[data-test=\"deploy-tile-all\"]").TextContent);
}
[Fact]
public void ClickingAStatusTile_AppliesTheFilterServerSide_AndResetsToPageOne()
{
var rows = Enumerable.Range(1, 25).Select(i => Row(i, $"Inst-{i}")).ToList();
RegisterServices(new DeploymentListPage(rows, TotalCount: 130));
var cut = Render<DeploymentsPage>();
cut.Find("[data-test=\"deploy-page-next\"]").Click();
_pageCalls.Clear();
cut.Find("[data-test=\"deploy-tile-failed\"]").Click();
var call = Assert.Single(_pageCalls);
Assert.Equal(DeploymentStatus.Failed, call.Filter.Status);
Assert.Equal(1, call.Page);
Assert.Equal("true", cut.Find("[data-test=\"deploy-tile-failed\"]").GetAttribute("aria-pressed"));
}
[Fact]
public void ClickingTheActiveTileAgain_ClearsTheStatusFilter()
{
RegisterServices(new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 1));
var cut = Render<DeploymentsPage>();
cut.Find("[data-test=\"deploy-tile-pending\"]").Click();
_pageCalls.Clear();
cut.Find("[data-test=\"deploy-tile-pending\"]").Click();
var call = Assert.Single(_pageCalls);
Assert.Null(call.Filter.Status);
Assert.Equal("true", cut.Find("[data-test=\"deploy-tile-all\"]").GetAttribute("aria-pressed"));
}
// ── Site scoping ──────────────────────────────────────────────────────────
[Fact]
public void SystemWideUser_QueriesWithNoSiteScope()
{
RegisterServices();
Render<DeploymentsPage>();
Assert.Null(Assert.Single(_pageCalls).Filter.SiteIdScope);
}
[Fact]
public void SiteScopedUser_PushesThePermittedSiteIdsIntoTheQuery()
{
RegisterServices(siteClaims: new[] { "3", "7" });
Render<DeploymentsPage>();
var scope = Assert.Single(_pageCalls).Filter.SiteIdScope;
Assert.NotNull(scope);
Assert.Equal(new[] { 3, 7 }, scope!.OrderBy(x => x));
}
// ── Filter debounce ───────────────────────────────────────────────────────
[Fact]
public void SearchInput_IsDebounced_SoABurstOfKeystrokesIssuesOneQuery()
{
RegisterServices(new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 1));
var cut = Render<DeploymentsPage>();
_pageCalls.Clear();
// Type "Reactor" one character at a time, well inside the 500ms window.
const string term = "Reactor";
for (var i = 1; i <= term.Length; i++)
{
cut.Find("[data-test=\"deploy-search\"]").Input(term[..i]);
}
// No query yet — the trailing debounce has not elapsed.
Assert.Empty(_pageCalls);
cut.WaitForAssertion(() => Assert.NotEmpty(_pageCalls), TimeSpan.FromSeconds(3));
Thread.Sleep(300);
var call = Assert.Single(_pageCalls);
Assert.Equal(term, call.Filter.Search);
Assert.Equal(1, call.Page);
}
[Fact]
public void ClearFilters_ResetsSearchAndStatus_AndRequeries()
{
RegisterServices(new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 1));
var cut = Render<DeploymentsPage>();
cut.Find("[data-test=\"deploy-tile-failed\"]").Click();
_pageCalls.Clear();
cut.Find("[data-test=\"deploy-clear-filters\"]").Click();
var call = Assert.Single(_pageCalls);
Assert.Null(call.Filter.Status);
Assert.Null(call.Filter.NormalizedSearch);
Assert.Equal(1, call.Page);
}
[Fact]
public void ClearFilters_IsEnabledAsSoonAsTheOperatorTypes_NotOnlyOnceTheDebounceFires()
{
RegisterServices(new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 1));
var cut = Render<DeploymentsPage>();
Assert.True(cut.Find("[data-test=\"deploy-clear-filters\"]").HasAttribute("disabled"));
cut.Find("[data-test=\"deploy-search\"]").Input("Rea");
// Still inside the 500ms window — the escape hatch must not be dead.
Assert.False(cut.Find("[data-test=\"deploy-clear-filters\"]").HasAttribute("disabled"));
}
[Fact]
public void DisposeWithAnArmedFilterTimer_DoesNotQuery()
{
RegisterServices(new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 1));
var cut = Render<DeploymentsPage>();
cut.Find("[data-test=\"deploy-search\"]").Input("abc");
cut.Instance.Dispose();
_pageCalls.Clear();
Thread.Sleep(900);
Assert.Empty(_pageCalls);
}
// ── Rendering ─────────────────────────────────────────────────────────────
[Fact]
public void InstanceName_ComesFromTheJoinedRow_NotAClientSideLookupTable()
{
RegisterServices(new DeploymentListPage(new[] { Row(42, "Line3.Filler") }, TotalCount: 1));
var cut = Render<DeploymentsPage>();
Assert.Contains("Line3.Filler", cut.Markup);
}
}