docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)

Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
This commit is contained in:
Joseph Doherty
2026-10-07 15:05:49 -04:00
parent 0ffd4bdbea
commit 513fa1aa16
998 changed files with 17896 additions and 2395 deletions
@@ -13,7 +13,7 @@ using ApiKeyForm = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Admin.ApiKey
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Admin;
/// <summary>
/// Bundle D drill-in test (#23 M7-T12) for the API Keys edit page, re-wired for the
/// Drill-in test for the API Keys edit page, re-wired for the
/// inbound-API-key re-arch (C3) onto the <see cref="IInboundApiKeyAdmin"/> seam. The chip
/// routes operators into the central Audit Log pre-filtered by Actor = key Name AND
/// Channel = ApiInbound (no other channel uses the key name as actor, but the explicit
@@ -25,6 +25,7 @@ public class ApiKeyFormAuditDrillinTests : BunitContext
private readonly IInboundApiKeyAdmin _admin = Substitute.For<IInboundApiKeyAdmin>();
private readonly IInboundApiRepository _repo = Substitute.For<IInboundApiRepository>();
/// <summary>Creates the test class and registers the stubbed services the page needs.</summary>
public ApiKeyFormAuditDrillinTests()
{
Services.AddSingleton(_admin);
@@ -50,6 +51,7 @@ public class ApiKeyFormAuditDrillinTests : BunitContext
string keyId, string name, bool enabled = true, params string[] methods) =>
new(keyId, name, enabled, methods, DateTimeOffset.UnixEpoch, null);
/// <summary>Verifies that the edit page has a recent audit activity link filtered by actor and the inbound API channel.</summary>
[Fact]
public void EditPage_HasRecentAuditActivityLink_WithActorAndApiInboundChannel()
{
@@ -72,6 +74,7 @@ public class ApiKeyFormAuditDrillinTests : BunitContext
});
}
/// <summary>Verifies that the create page has no recent audit activity link.</summary>
[Fact]
public void CreatePage_HasNoRecentAuditActivityLink()
{
@@ -86,6 +89,8 @@ public class ApiKeyFormAuditDrillinTests : BunitContext
});
}
/// <summary>Verifies that creating a key with a method selected shows the one-time token and key id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CreatePage_WithMethodSelected_ShowsOneTimeTokenAndKeyId()
{
@@ -118,6 +123,8 @@ public class ApiKeyFormAuditDrillinTests : BunitContext
Arg.Any<CancellationToken>());
}
/// <summary>Verifies that creating a key with no method selected shows a validation error and creates nothing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CreatePage_WithNoMethodSelected_ShowsValidationError_AndDoesNotCreate()
{
@@ -19,6 +19,7 @@ public class ApiKeysListPageTests : BunitContext
{
private readonly IInboundApiKeyAdmin _admin = Substitute.For<IInboundApiKeyAdmin>();
/// <summary>Initializes the test context and registers the fake API key seam.</summary>
public ApiKeysListPageTests()
{
Services.AddSingleton(_admin);
@@ -40,6 +41,7 @@ public class ApiKeysListPageTests : BunitContext
string keyId, string name, bool enabled, params string[] methods) =>
new(keyId, name, enabled, methods, DateTimeOffset.UnixEpoch, null);
/// <summary>Verifies that the list renders the keys from the seam and shows no key hash column.</summary>
[Fact]
public void List_RendersKeysFromSeam_WithNoKeyHashColumn()
{
@@ -76,6 +78,8 @@ public class ApiKeysListPageTests : BunitContext
});
}
/// <summary>Verifies that toggling a key calls SetEnabled on the seam.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ToggleKey_CallsSetEnabledOnSeam()
{
@@ -15,7 +15,7 @@ using SiteForm = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Admin.SiteForm
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Admin;
/// <summary>
/// Bundle D drill-in test (#23 M7-T12) for the Site edit page. The chip
/// Drill-in test for the Site edit page. The chip
/// routes operators into the central Audit Log pre-filtered by SourceSiteId =
/// Site.SiteIdentifier (the same string the audit pipeline stamps onto every
/// site-sourced row). Create mode suppresses the link — there's no site yet.
@@ -25,6 +25,7 @@ public class SiteFormAuditDrillinTests : BunitContext
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly CommunicationService _comms;
/// <summary>Initializes the test context with a stubbed site repository, communication service and an administrator identity.</summary>
public SiteFormAuditDrillinTests()
{
_comms = new CommunicationService(
@@ -45,6 +46,7 @@ public class SiteFormAuditDrillinTests : BunitContext
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
}
/// <summary>Verifies that the edit page links to recent audit activity filtered by the site identifier.</summary>
[Fact]
public void EditPage_HasRecentAuditActivityLink_WithSiteEqualToSiteIdentifier()
{
@@ -61,6 +63,7 @@ public class SiteFormAuditDrillinTests : BunitContext
});
}
/// <summary>Verifies that the create page shows no recent audit activity link.</summary>
[Fact]
public void CreatePage_HasNoRecentAuditActivityLink()
{
@@ -19,7 +19,7 @@ using SitesPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Admin.Sites;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Admin;
/// <summary>
/// Regression tests for CentralUI-012. The Sites page loaded all sites and then
/// Regression tests for the Sites page N+1 query fix. The Sites page loaded all sites and then
/// issued <c>GetDataConnectionsBySiteIdAsync</c> once per site (N+1 database
/// round-trips on every load and post-delete refresh). The fix fetches all
/// data connections in a single <c>GetAllDataConnectionsAsync</c> call and
@@ -73,7 +73,10 @@ public class SitesPageTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Initializes a new instance of the <see cref="StubAuthStateProvider"/> class.</summary>
/// <param name="state">The authentication state returned to callers.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
@@ -105,6 +108,7 @@ public class SitesPageTests : BunitContext
stale,
unknown);
/// <summary>Verifies that loading data fetches all connections in one query rather than one query per site.</summary>
[Fact]
public void LoadData_FetchesAllConnectionsInOneQuery_NoPerSiteQueries()
{
@@ -122,6 +126,7 @@ public class SitesPageTests : BunitContext
_siteRepo.DidNotReceive().GetDataConnectionsBySiteIdAsync(Arg.Any<int>());
}
/// <summary>Verifies that loading data groups connections by site and renders them under their site.</summary>
[Fact]
public void LoadData_GroupsConnectionsBySite_AndRendersThem()
{
@@ -138,6 +143,7 @@ public class SitesPageTests : BunitContext
Assert.Contains("beta-conn", cut.Markup);
}
/// <summary>Verifies that the artifact badge shows Current, Out of date or Never deployed according to the site's artifact state.</summary>
[Fact]
public void ArtifactBadge_ShowsCurrent_OutOfDate_NeverDeployed()
{
@@ -163,6 +169,7 @@ public class SitesPageTests : BunitContext
});
}
/// <summary>Verifies that the instance line shows the stale count out of the deployed count.</summary>
[Fact]
public void InstanceLine_ShowsStaleOfDeployed()
{
@@ -183,6 +190,7 @@ public class SitesPageTests : BunitContext
});
}
/// <summary>Verifies that the instance line uses the correct singular wording when exactly one instance is affected.</summary>
[Fact]
public void InstanceLine_PluralisesTheSingleInstanceCase()
{
@@ -204,6 +212,7 @@ public class SitesPageTests : BunitContext
});
}
/// <summary>Verifies that Re-check forces a refresh of the pending-updates snapshot.</summary>
[Fact]
public void Recheck_ForcesRefresh()
{
@@ -218,6 +227,7 @@ public class SitesPageTests : BunitContext
_pendingUpdates.Received().GetAsync(true, Arg.Any<CancellationToken>()));
}
/// <summary>Verifies that a failure loading sites skips the fleet-wide evaluation.</summary>
[Fact]
public void SiteLoadFailure_SkipsTheFleetEvaluation()
{
@@ -232,6 +242,7 @@ public class SitesPageTests : BunitContext
_pendingUpdates.DidNotReceive().GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a failed re-check clears the stale as-of caption.</summary>
[Fact]
public void FailedRecheck_ClearsTheStaleAsOfCaption()
{
@@ -258,6 +269,7 @@ public class SitesPageTests : BunitContext
});
}
/// <summary>Verifies that a cache fault shows an unavailable state while the sites still render.</summary>
[Fact]
public void CacheFault_ShowsUnavailable_AndSitesStillRender()
{
@@ -24,7 +24,7 @@ using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Audit;
/// <summary>
/// Endpoint-level tests for the Audit Log CSV export (#23 M7-T14 / Bundle F).
/// Endpoint-level tests for the Audit Log CSV export.
///
/// <para>
/// CentralUI uses minimal-API endpoints (see <c>AuthEndpoints</c> /
@@ -38,7 +38,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Audit;
/// </summary>
public class AuditExportEndpointsTests
{
// C3 (Task 2.5): the export endpoint reads canonical ZB.MOM.WW.Audit.AuditEvent
// The export endpoint reads canonical ZB.MOM.WW.Audit.AuditEvent
// rows straight off IAuditLogRepository.QueryAsync; build them via the factory.
private static AuditEvent SampleEvent() =>
ScadaBridgeAuditEventFactory.Create(
@@ -71,7 +71,7 @@ public class AuditExportEndpointsTests
web.ConfigureServices(services =>
{
services.AddRouting();
// The endpoint is AuditExport-gated (#23 M7-T15 Bundle G);
// The endpoint is AuditExport-gated;
// the tests run as pre-authenticated principals built by
// FakeAuthHandler (everyone has the Admin role), which is
// one of AuditExportRoles, so the policy succeeds.
@@ -79,7 +79,7 @@ public class AuditExportEndpointsTests
.AddScheme<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions, FakeAuthHandler>(
FakeAuthHandler.SchemeName, _ => { });
// Use the real production policy wiring so the endpoint's
// updated AuditExport gate (#23 M7-T15 Bundle G) is what
// updated AuditExport gate is what
// the tests exercise. The fake principal carries the
// "Administrator" role, which AuditExportRoles permits.
services.AddScadaBridgeAuthorization();
@@ -103,6 +103,8 @@ public class AuditExportEndpointsTests
return (client, repo, host);
}
/// <summary>Verifies that a GET to the export endpoint returns a CSV content type and an attachment content disposition.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExportEndpoint_Get_ReturnsCsvContentType_AndAttachmentDisposition()
{
@@ -131,6 +133,8 @@ public class AuditExportEndpointsTests
}
}
/// <summary>Verifies that the export endpoint parses the query string into a filter and passes it to the export service.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExportEndpoint_PassesFilterFromQueryString_ToService()
{
@@ -180,6 +184,8 @@ public class AuditExportEndpointsTests
}
}
/// <summary>Verifies that a request without a query string passes an empty filter to the export service.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExportEndpoint_NoQueryString_PassesEmptyFilter()
{
@@ -210,11 +216,13 @@ public class AuditExportEndpointsTests
}
}
/// <summary>Verifies that an unrecognized enum value in the query string is silently ignored instead of failing the export.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExportEndpoint_UnknownEnumValue_SilentlyIgnored()
{
// Defensive parsing: a junk channel value MUST NOT 500 the export —
// mirrors the page-level query-string parser (#23 M7 Bundle D) which
// mirrors the page-level query-string parser, which
// silently drops unrecognised values.
var (client, repo, host) = await BuildHostAsync();
using (host)
@@ -230,6 +238,8 @@ public class AuditExportEndpointsTests
}
}
/// <summary>Verifies that an unparseable execution id in the query string is silently dropped from the filter.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExportEndpoint_UnparseableExecutionId_SilentlyDropped()
{
@@ -249,6 +259,8 @@ public class AuditExportEndpointsTests
}
}
/// <summary>Verifies that an unparseable parent execution id in the query string is silently dropped from the filter.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExportEndpoint_UnparseableParentExecutionId_SilentlyDropped()
{
@@ -277,12 +289,17 @@ public class AuditExportEndpointsTests
{
public const string SchemeName = "FakeAuth";
/// <summary>Initializes the fake authentication handler.</summary>
/// <param name="options">The authentication scheme options monitor.</param>
/// <param name="logger">The logger factory.</param>
/// <param name="encoder">The URL encoder.</param>
public FakeAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder) { }
/// <inheritdoc />
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
@@ -297,6 +314,7 @@ public class AuditExportEndpointsTests
}
}
/// <summary>Verifies that the audit export route is registered as a GET endpoint.</summary>
[Fact]
public void ExportEndpoint_RouteIsRegistered()
{
@@ -9,13 +9,14 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Audit;
/// <summary>
/// Flow Debugger drill-ins (design 2026-09-05 §4, plan Task 21): the audit drawer's
/// Flow Debugger drill-ins (design 2026-09-05 §4): the audit drawer's
/// detail body and the Execution Chain page both offer <em>Open in Flow Debugger</em>,
/// and the inbound-method form carries the <c>TraceAllCalls</c> checkbox wired to both
/// save paths.
/// </summary>
public class FlowDebuggerDrillinTests : BunitContext
{
/// <summary>Initializes the test context with the services the drill-in components need.</summary>
public FlowDebuggerDrillinTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
@@ -49,6 +50,7 @@ public class FlowDebuggerDrillinTests : BunitContext
private static string Read(params string[] relative)
=> File.ReadAllText(Path.Combine([SrcRoot, .. relative]));
/// <summary>Verifies that an audit event with an execution id offers an Open in Flow Debugger button that navigates to the replay.</summary>
[Fact]
public void AuditEventDetail_WithExecutionId_OffersOpenInFlowDebugger_AndNavigatesToReplay()
{
@@ -61,6 +63,7 @@ public class FlowDebuggerDrillinTests : BunitContext
Assert.Contains($"/deployment/flow-debugger?executionId={exec}", nav.Uri);
}
/// <summary>Verifies that an audit event without an execution id hides the Open in Flow Debugger button.</summary>
[Fact]
public void AuditEventDetail_WithoutExecutionId_HidesTheButton()
{
@@ -69,6 +72,7 @@ public class FlowDebuggerDrillinTests : BunitContext
Assert.DoesNotContain("data-test=\"open-flow-debugger\"", cut.Markup);
}
/// <summary>Verifies that the execution tree page links to the Flow Debugger replay beside the audit log link.</summary>
[Fact]
public void ExecutionTreePage_LinksToTheFlowDebuggerReplay_BesideTheAuditLogLink()
{
@@ -81,6 +85,7 @@ public class FlowDebuggerDrillinTests : BunitContext
< markup.IndexOf("open-flow-debugger", StringComparison.Ordinal));
}
/// <summary>Verifies that the API method form has a trace-all-calls checkbox wired to both the create and update save paths.</summary>
[Fact]
public void ApiMethodForm_HasTraceAllCallsCheckbox_WiredToBothSavePaths()
{
@@ -14,7 +14,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
/// <summary>
/// Regression tests for CentralUI-017. <c>POST /auth/logout</c> called
/// Regression tests for the logout CSRF fix. <c>POST /auth/logout</c> called
/// <c>.DisableAntiforgery()</c> and a plain <c>GET /logout</c> route also
/// signed the user out — either could be triggered cross-site to forcibly log
/// a user out. Logout is a state-changing authenticated action and must be
@@ -48,6 +48,7 @@ public class AuthEndpointsCsrfTests
e.RoutePattern.RawText == pattern &&
(e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods.Contains(method) ?? false));
/// <summary>Verifies that the logout POST route does not disable antiforgery validation.</summary>
[Fact]
public void PostAuthLogout_DoesNotDisableAntiforgery()
{
@@ -63,6 +64,7 @@ public class AuthEndpointsCsrfTests
"POST /auth/logout must keep antiforgery validation enabled.");
}
/// <summary>Verifies that the state-changing GET logout route has been removed.</summary>
[Fact]
public void GetLogout_StateChangingRoute_IsRemoved()
{
@@ -72,6 +74,8 @@ public class AuthEndpointsCsrfTests
Assert.Null(getLogout);
}
/// <summary>Verifies that a login POST with a valid antiforgery token authenticates the user and sets the session cookie.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PostAuthLogin_ValidTokenAuthenticatesAndSetsCookie()
{
@@ -105,6 +109,8 @@ public class AuthEndpointsCsrfTests
Assert.Contains(context.Response.Headers.SetCookie, value => value!.StartsWith(".AspNetCore.Cookies="));
}
/// <summary>Verifies that a login POST with a missing antiforgery token is rejected before any credential lookup occurs.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PostAuthLogin_MissingTokenRejectsBeforeCredentialLookup()
{
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
/// <summary>
/// Regression tests for CentralUI-020. The Blazor circuit's
/// Regression tests for session-expiry detection. The Blazor circuit's
/// <c>CookieAuthenticationStateProvider</c> serves a frozen constructor-time
/// principal, so <c>SessionExpiry</c> could never observe a server-side cookie
/// expiry by polling the auth state. The fix adds <c>GET /auth/ping</c>, an
@@ -43,6 +43,7 @@ public class AuthPingEndpointTests
e.RoutePattern.RawText == pattern &&
(e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods.Contains(method) ?? false));
/// <summary>Verifies that the GET route for the auth ping endpoint is mapped.</summary>
[Fact]
public void AuthPing_GetRoute_IsMapped()
{
@@ -50,6 +51,8 @@ public class AuthPingEndpointTests
Assert.NotNull(ping);
}
/// <summary>Verifies that an anonymous user receives a 401 response from the auth ping endpoint.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuthPing_AnonymousUser_Returns401()
{
@@ -63,6 +66,8 @@ public class AuthPingEndpointTests
Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode);
}
/// <summary>Verifies that an authenticated user receives a 200 response from the auth ping endpoint.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuthPing_AuthenticatedUser_Returns200()
{
@@ -78,6 +83,7 @@ public class AuthPingEndpointTests
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
}
/// <summary>Verifies that the auth ping endpoint returns a status code instead of redirecting through the cookie middleware.</summary>
[Fact]
public void AuthPing_DoesNotTriggerCookieRedirect()
{
@@ -14,6 +14,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
public class CircuitRevocationRegressionTests
{
/// <summary>Verifies that an explicit server auto-login does not remap the synthetic cookie principal.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExplicitServerAutoLogin_DoesNotRemapSyntheticCookiePrincipal()
{
@@ -38,6 +40,8 @@ public class CircuitRevocationRegressionTests
await mapper.DidNotReceive().MapAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that the same circuit rechecks role mapping and site scope after a grant is revoked.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SameCircuit_RechecksMappingAndScope_AfterGrantRevocation()
{
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
/// <summary>
/// Regression tests for CentralUI-024. Ten components each copy-pasted a
/// Regression tests for the shared claim lookups. Ten components each copy-pasted a
/// <c>GetCurrentUserAsync</c> helper using the magic string
/// <c>FindFirst("Username")</c>, and <c>NavMenu</c>/<c>Dashboard</c> used
/// <c>FindFirst("DisplayName")</c>. A rename of the claim type in
@@ -20,6 +20,7 @@ public class ClaimsPrincipalExtensionsTests
private static ClaimsPrincipal Principal(params Claim[] claims)
=> new(new ClaimsIdentity(claims, authenticationType: "TestCookie"));
/// <summary>Verifies that the username lookup resolves the claim the JWT token service issues.</summary>
[Fact]
public void GetUsername_ResolvesTheJwtTokenServiceUsernameClaim()
{
@@ -29,6 +30,7 @@ public class ClaimsPrincipalExtensionsTests
Assert.Equal("alice", principal.GetUsername());
}
/// <summary>Verifies that the username lookup falls back to unknown when the claim is absent.</summary>
[Fact]
public void GetUsername_FallsBackToUnknown_WhenClaimAbsent()
{
@@ -37,6 +39,7 @@ public class ClaimsPrincipalExtensionsTests
Assert.Equal(ClaimsPrincipalExtensions.UnknownUser, principal.GetUsername());
}
/// <summary>Verifies that the display-name lookup resolves the claim the JWT token service issues.</summary>
[Fact]
public void GetDisplayName_ResolvesTheJwtTokenServiceDisplayNameClaim()
{
@@ -46,12 +49,15 @@ public class ClaimsPrincipalExtensionsTests
Assert.Equal("Alice Anderson", principal.GetDisplayName());
}
/// <summary>Verifies that the display-name lookup returns null when the claim is absent.</summary>
[Fact]
public void GetDisplayName_IsNull_WhenClaimAbsent()
{
Assert.Null(Principal().GetDisplayName());
}
/// <summary>Verifies that the current-username lookup reads the username from the authentication state.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetCurrentUsernameAsync_ReadsUsernameFromAuthState()
{
@@ -63,6 +69,7 @@ public class ClaimsPrincipalExtensionsTests
Assert.Equal("bob", await provider.GetCurrentUsernameAsync());
}
/// <summary>Verifies that the username lookup follows a rename of the claim type in the JWT token service.</summary>
[Fact]
public void Username_LookupTracksAJwtTokenServiceRename()
{
@@ -81,7 +88,10 @@ public class ClaimsPrincipalExtensionsTests
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Initializes the provider with a fixed authentication state.</summary>
/// <param name="state">The authentication state to return.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
/// <summary>
/// Regression tests for CentralUI-004. The provider used to read
/// Regression tests for the cookie authentication state provider. The provider used to read
/// <see cref="IHttpContextAccessor.HttpContext"/> on every call; once the Blazor
/// circuit is established that context is gone, so later re-evaluations saw an
/// unauthenticated principal. The provider must snapshot the principal once at
@@ -21,6 +21,8 @@ public class CookieAuthenticationStateProviderTests
return new ClaimsPrincipal(identity);
}
/// <summary>Verifies that the provider returns the authenticated user while the HTTP context is present.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetAuthenticationStateAsync_ReturnsAuthenticatedUser_WhenHttpContextPresent()
{
@@ -36,6 +38,8 @@ public class CookieAuthenticationStateProviderTests
Assert.Equal("alice", state.User.Identity?.Name);
}
/// <summary>Verifies that the provider still returns the user after the HTTP context is gone.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetAuthenticationStateAsync_StillReturnsUser_AfterHttpContextIsGone()
{
@@ -57,6 +61,8 @@ public class CookieAuthenticationStateProviderTests
Assert.Equal("bob", state.User.Identity?.Name);
}
/// <summary>Verifies that the provider returns the same user across calls and ignores a stale foreign HTTP context.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetAuthenticationStateAsync_IsStableAcrossCalls_IgnoringStaleForeignContext()
{
@@ -20,6 +20,9 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
public class ParkedMessagesMutationRegressionTests : BunitContext
{
/// <summary>Verifies that a retry for a site with a non-numeric identifier is relayed only while the user's current site grant allows it.</summary>
/// <param name="revoke"><c>true</c> to revoke the user's site grant before retrying, <c>false</c> to keep it.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -69,7 +72,9 @@ public class ParkedMessagesMutationRegressionTests : BunitContext
private sealed class MutableAuth(string siteId) : AuthenticationStateProvider
{
/// <summary>Gets or sets the site identifier carried in the user's site-scope claim.</summary>
public string SiteId { get; set; } = siteId;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity(new[] {
new Claim(JwtTokenService.UsernameClaimType, "operator"),
@@ -80,6 +85,8 @@ public class ParkedMessagesMutationRegressionTests : BunitContext
private sealed class ReplyActor : ReceiveActor
{
/// <summary>Initializes the actor with the queue that records relayed retry requests.</summary>
/// <param name="dispatched">The queue that receives each relayed retry envelope.</param>
public ReplyActor(ConcurrentQueue<SiteEnvelope> dispatched)
{
Receive<SiteEnvelope>(envelope =>
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
public class RoutePolicyCoverageTests
{
/// <summary>Verifies that every routable component has its reviewed authorization policy, including the external secrets pages.</summary>
[Fact]
public void EveryRoutableComponent_HasItsReviewedPolicy_IncludingExternalSecrets()
{
@@ -7,19 +7,21 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
/// <summary>
/// Regression tests for CentralUI-020 and CentralUI-025. <c>SessionExpiry</c>
/// Regression tests for the session-expiry redirect. <c>SessionExpiry</c>
/// used to poll the Blazor <c>AuthenticationStateProvider</c>, which (via
/// <c>CookieAuthenticationStateProvider</c>) serves a frozen constructor-time
/// principal — so the polled state could never become "expired" and the
/// idle-logout redirect never fired. The component now polls the server
/// <c>GET /auth/ping</c> endpoint, which reflects the live cookie session: a
/// 401 response triggers a redirect to <c>/login</c>. These tests exercise that
/// redirect path directly (CentralUI-025: the path was previously untested).
/// redirect path directly (the path was previously untested).
/// </summary>
public class SessionExpiryComponentTests : BunitContext
{
private const string ModulePath = "./_content/ZB.MOM.WW.ScadaBridge.CentralUI/js/session-expiry.js";
/// <summary>Verifies that an expired session redirects to the login page.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CheckSession_ExpiredSession_RedirectsToLogin()
{
@@ -35,6 +37,8 @@ public class SessionExpiryComponentTests : BunitContext
Assert.EndsWith("/login", nav.Uri);
}
/// <summary>Verifies that a live session does not redirect.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CheckSession_LiveSession_DoesNotRedirect()
{
@@ -52,6 +56,8 @@ public class SessionExpiryComponentTests : BunitContext
Assert.DoesNotContain("/login", nav.Uri);
}
/// <summary>Verifies that a transient network failure does not redirect.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CheckSession_TransientNetworkFailure_DoesNotRedirect()
{
@@ -69,6 +75,8 @@ public class SessionExpiryComponentTests : BunitContext
Assert.Equal(before, nav.Uri);
}
/// <summary>Verifies that checking the session on the login page neither pings the server nor redirects.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CheckSession_OnLoginPage_DoesNotPingOrRedirect()
{
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
/// <summary>
/// Regression tests for CentralUI-005. <c>AuthEndpoints</c> previously stamped a
/// Regression tests for session expiry. <c>AuthEndpoints</c> previously stamped a
/// fixed <c>expires_at = UtcNow + 30 min</c> claim and a 30-minute absolute cookie
/// <c>ExpiresUtc</c> with no sliding refresh, contradicting the documented
/// "sliding refresh, 30-minute idle timeout" policy. The login handler must now
@@ -14,6 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
/// </summary>
public class SessionExpiryPolicyTests
{
/// <summary>Verifies that the sign-in properties do not impose a fixed absolute expiry.</summary>
[Fact]
public void BuildSignInProperties_DoesNotSetFixedAbsoluteExpiry()
{
@@ -25,6 +26,7 @@ public class SessionExpiryPolicyTests
Assert.Null(props.ExpiresUtc);
}
/// <summary>Verifies that the sign-in properties request a persistent cookie.</summary>
[Fact]
public void BuildSignInProperties_IsPersistent()
{
@@ -33,6 +35,7 @@ public class SessionExpiryPolicyTests
Assert.True(props.IsPersistent);
}
/// <summary>Verifies that the sign-in properties allow a sliding refresh of the cookie.</summary>
[Fact]
public void BuildSignInProperties_AllowsSlidingRefresh()
{
@@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
/// <summary>
/// Regression tests for CentralUI-002. Site-scoped Deployment permissions are
/// Regression tests for site-scoped permissions. Site-scoped Deployment permissions are
/// written as <c>SiteId</c> claims at login but were never read — Deployment
/// pages listed and acted on every site. <see cref="SiteScopeService"/> is the
/// shared helper that reads those claims; these tests pin its behaviour.
@@ -17,7 +17,10 @@ public class SiteScopeServiceTests
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly ClaimsPrincipal _user;
/// <summary>Initializes a new instance of the <c>StubAuthStateProvider</c> class.</summary>
/// <param name="user">The user.</param>
public StubAuthStateProvider(ClaimsPrincipal user) => _user = user;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(new AuthenticationState(_user));
}
@@ -34,6 +37,8 @@ public class SiteScopeServiceTests
private static List<Site> Sites(params int[] ids)
=> ids.Select(id => new Site($"Site{id}", $"SITE-{id}") { Id = id }).ToList();
/// <summary>Verifies that a deployer with no site claims is treated as system-wide.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploymentUserWithNoSiteClaims_IsSystemWide()
{
@@ -43,6 +48,8 @@ public class SiteScopeServiceTests
Assert.Empty(await svc.PermittedSiteIdsAsync());
}
/// <summary>Verifies that filtering sites for a system-wide user returns every site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SystemWideUser_FilterSites_ReturnsAllSites()
{
@@ -53,6 +60,8 @@ public class SiteScopeServiceTests
Assert.Equal(new[] { 1, 2, 3 }, filtered.Select(s => s.Id));
}
/// <summary>Verifies that filtering sites for a scoped user returns only the permitted sites.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ScopedUser_FilterSites_ReturnsOnlyPermittedSites()
{
@@ -64,6 +73,8 @@ public class SiteScopeServiceTests
Assert.Equal(new[] { 1, 3 }, filtered.Select(s => s.Id).OrderBy(x => x));
}
/// <summary>Verifies that a scoped user is allowed access only to the sites they were granted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ScopedUser_IsSiteAllowed_OnlyForGrantedSites()
{
@@ -73,6 +84,8 @@ public class SiteScopeServiceTests
Assert.False(await svc.IsSiteAllowedAsync(6));
}
/// <summary>Verifies that a scoped user is not system-wide and reports its permitted site ids.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ScopedUser_IsNotSystemWide_AndReportsItsPermittedIds()
{
@@ -82,6 +95,8 @@ public class SiteScopeServiceTests
Assert.Equal(new[] { 7, 9 }, (await svc.PermittedSiteIdsAsync()).OrderBy(x => x));
}
/// <summary>Verifies that a system-wide user is allowed access to any site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SystemWideUser_IsSiteAllowed_ForAnySite()
{
@@ -99,6 +114,8 @@ public class SiteScopeServiceTests
// could act fleet-wide through the CLI/HTTP surface while the UI hid the same
// sites. The UI was the side that disagreed with the documented rule.
/// <summary>Verifies that an administrator carrying site claims is still system-wide.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AdministratorWithSiteClaims_IsSystemWide()
{
@@ -107,6 +124,8 @@ public class SiteScopeServiceTests
Assert.True(await svc.IsSystemWideAsync());
}
/// <summary>Verifies that filtering sites for an administrator with site claims returns every site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AdministratorWithSiteClaims_FilterSites_ReturnsAllSites()
{
@@ -117,6 +136,8 @@ public class SiteScopeServiceTests
Assert.Equal(new[] { 1, 2, 3 }, filtered.Select(s => s.Id));
}
/// <summary>Verifies that an administrator with site claims is allowed access to sites outside those claims.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AdministratorWithSiteClaims_IsSiteAllowed_ForSitesOutsideTheClaims()
{
@@ -125,6 +146,8 @@ public class SiteScopeServiceTests
Assert.True(await svc.IsSiteAllowedAsync(2));
}
/// <summary>Verifies that an administrator with site claims reports no permitted site ids.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AdministratorWithSiteClaims_ReportsNoPermittedIds()
{
@@ -136,6 +159,9 @@ public class SiteScopeServiceTests
Assert.Empty(await svc.PermittedSiteIdsAsync());
}
/// <summary>Verifies that the administrator role match ignores case.</summary>
/// <param name="role">The role.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("administrator")]
[InlineData("ADMINISTRATOR")]
@@ -150,6 +176,8 @@ public class SiteScopeServiceTests
Assert.True(await svc.IsSystemWideAsync());
}
/// <summary>Verifies that a designer with site claims remains scoped to those sites.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DesignerWithSiteClaims_IsStillScoped()
{
@@ -172,6 +200,8 @@ public class SiteScopeServiceTests
private static ClaimsPrincipal Principal(params Claim[] claims)
=> new(new ClaimsIdentity(claims, authenticationType: "TestCookie"));
/// <summary>Verifies that resolving an administrator yields a system-wide scope with an empty site set.</summary>
/// <returns>The result.</returns>
[Fact]
public void Resolve_Administrator_IsSystemWideWithAnEmptySet()
{
@@ -184,6 +214,8 @@ public class SiteScopeServiceTests
Assert.Empty(sites);
}
/// <summary>Verifies that resolving a principal with site claims yields the scoped site set.</summary>
/// <returns>The result.</returns>
[Fact]
public void Resolve_SiteClaims_YieldTheScopedSet()
{
@@ -194,6 +226,8 @@ public class SiteScopeServiceTests
Assert.Equal([1, 3], sites.OrderBy(id => id));
}
/// <summary>Verifies that resolving a principal with no site claims yields a system-wide scope.</summary>
/// <returns>The result.</returns>
[Fact]
public void Resolve_NoSiteClaims_IsSystemWide()
{
@@ -203,6 +237,8 @@ public class SiteScopeServiceTests
Assert.Empty(sites);
}
/// <summary>Verifies that resolving an unauthenticated principal yields a scope with no permitted sites.</summary>
/// <returns>The result.</returns>
[Fact]
public void Resolve_UnauthenticatedPrincipal_IsScopedToNothing()
{
@@ -213,6 +249,8 @@ public class SiteScopeServiceTests
Assert.Empty(sites);
}
/// <summary>Verifies that the synchronous resolve produces the same result as the async path.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Resolve_AgreesWithTheAsyncPath()
{
@@ -22,6 +22,9 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
public class UiAuditRegressionTests : BunitContext
{
/// <summary>Verifies that creating an LDAP mapping from the UI persists its audit row with the generated mapping id, or rolls back when the audit write fails.</summary>
/// <param name="failAudit">Whether the audit write is made to fail.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -90,6 +93,7 @@ public class UiAuditRegressionTests : BunitContext
private sealed class StaticAuth(System.Security.Claims.ClaimsPrincipal principal) : AuthenticationStateProvider
{
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(principal));
}
}
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
/// </summary>
public class ComponentRenderingTests : BunitContext
{
/// <summary>Verifies that the login page renders a form with username and password fields.</summary>
[Fact]
public void LoginPage_RendersForm_WithUsernameAndPasswordFields()
{
@@ -37,6 +38,7 @@ public class ComponentRenderingTests : BunitContext
Assert.Contains("Sign in", submitButton.TextContent);
}
/// <summary>Verifies that the login page does not render an alert when there is no error.</summary>
[Fact]
public void LoginPage_WithoutError_DoesNotRenderAlert()
{
@@ -45,6 +47,7 @@ public class ComponentRenderingTests : BunitContext
Assert.Throws<Bunit.ElementNotFoundException>(() => cut.Find("div.panel.notice.login-error"));
}
/// <summary>Verifies that the dashboard page requires an authorize attribute.</summary>
[Fact]
public void Dashboard_RequiresAuthorizeAttribute()
{
@@ -53,6 +56,7 @@ public class ComponentRenderingTests : BunitContext
Assert.NotEmpty(authorizeAttrs);
}
/// <summary>Verifies that the templates page requires the Design authorization policy.</summary>
[Fact]
public void TemplateEditor_RequiresDesignPolicy()
{
@@ -64,6 +68,7 @@ public class ComponentRenderingTests : BunitContext
Assert.Equal("RequireDesign", attr.Policy);
}
/// <summary>Verifies that the login page renders the ScadaBridge title.</summary>
[Fact]
public void LoginPage_RendersScadaBridgeTitle()
{
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components;
/// <summary>
/// Covers the <c>AlarmStateBadges</c> shared component lifted out of
/// <c>DebugView</c> (M7-T13 prep). The badge cluster must render identically
/// <c>DebugView</c>. The badge cluster must render identically
/// wherever it is hosted, so these tests pin the rendered markup: state badge
/// class, kind label, native sub-state badges, severity, and HiLo level.
/// </summary>
@@ -32,6 +32,7 @@ public class AlarmStateBadgesTests : BunitContext
Severity: 700),
};
/// <summary>Verifies that the state, kind, sub-state, severity and level badges are rendered.</summary>
[Fact]
public void Renders_StateKindSubStateSeverityAndLevel_Badges()
{
@@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="AuditDrilldownDrawer"/> (#23 M7 Bundle C / M7-T4..T8).
/// bUnit tests for <see cref="AuditDrilldownDrawer"/>.
///
/// The drawer is a child component opened from the Audit Log page when a grid row
/// is clicked. It renders the offcanvas chrome (header, open/close) and delegates
@@ -24,6 +24,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
/// </summary>
public class AuditDrilldownDrawerTests : BunitContext
{
/// <summary>Initializes the test context with loose JS interop and the services the drawer needs.</summary>
public AuditDrilldownDrawerTests()
{
// Default to Loose so the cURL clipboard call does not blow up tests
@@ -70,6 +71,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Extra = extra,
};
/// <summary>Verifies that the drawer renders the occurred-at UTC field.</summary>
[Fact]
public void Drawer_RendersField_OccurredAtUtc()
{
@@ -86,6 +88,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.Contains("2026-05-20T12:30:45", cut.Markup);
}
/// <summary>Verifies that a JSON request summary is pretty-printed with indentation.</summary>
[Fact]
public void Drawer_JsonRequestSummary_PrettyPrinted_Indented()
{
@@ -104,6 +107,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.Matches(@"\n\s+""b"":\s*""two""", cut.Markup);
}
/// <summary>Verifies that a non-JSON request summary is rendered verbatim.</summary>
[Fact]
public void Drawer_NonJsonRequestSummary_RenderedVerbatim()
{
@@ -118,6 +122,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.Contains("not really json {{}", cut.Markup);
}
/// <summary>Verifies that a DbOutbound event renders its SQL block.</summary>
[Fact]
public void Drawer_DbOutboundChannel_RendersSqlBlock()
{
@@ -140,6 +145,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.Contains("abc", cut.Markup);
}
/// <summary>Verifies that an API outbound event shows the copy-as-cURL button.</summary>
[Fact]
public void Drawer_ApiOutbound_ShowsCopyAsCurlButton()
{
@@ -152,6 +158,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.Contains("data-test=\"copy-as-curl\"", cut.Markup);
}
/// <summary>Verifies that a non-API channel event hides the copy-as-cURL button.</summary>
[Fact]
public void Drawer_NotApiChannel_HidesCopyAsCurlButton()
{
@@ -165,6 +172,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.DoesNotContain("data-test=\"copy-as-curl\"", cut.Markup);
}
/// <summary>Verifies that a null correlation id hides the show-all button.</summary>
[Fact]
public void Drawer_NullCorrelationId_HidesShowAllButton()
{
@@ -177,6 +185,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.DoesNotContain("data-test=\"show-all-events\"", cut.Markup);
}
/// <summary>Verifies that a redacted body shows the redaction badge.</summary>
[Fact]
public void Drawer_RedactedBody_ShowsRedactionBadge()
{
@@ -191,6 +200,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.Contains("data-test=\"redaction-badge-request\"", cut.Markup);
}
/// <summary>Verifies that a non-redacted body hides the redaction badge.</summary>
[Fact]
public void Drawer_NonRedactedBody_HidesBadge()
{
@@ -203,6 +213,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.DoesNotContain("data-test=\"redaction-badge-request\"", cut.Markup);
}
/// <summary>Verifies that show-all-for-operation navigates with the correlation id in the query string.</summary>
[Fact]
public void ShowAllForOperation_Navigates_WithCorrelationIdQueryString()
{
@@ -220,6 +231,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.Contains(corr.ToString(), nav.Uri);
}
/// <summary>Verifies that a null execution id hides the view-this-execution button.</summary>
[Fact]
public void Drawer_NullExecutionId_HidesViewThisExecutionButton()
{
@@ -232,6 +244,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.DoesNotContain("data-test=\"view-this-execution\"", cut.Markup);
}
/// <summary>Verifies that a non-null execution id shows the view-this-execution button.</summary>
[Fact]
public void Drawer_NonNullExecutionId_ShowsViewThisExecutionButton()
{
@@ -244,6 +257,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.Contains("data-test=\"view-this-execution\"", cut.Markup);
}
/// <summary>Verifies that view-this-execution navigates with the execution id in the query string.</summary>
[Fact]
public void ViewThisExecution_Navigates_WithExecutionIdQueryString()
{
@@ -260,6 +274,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.Contains($"/audit/log?executionId={exec}", nav.Uri);
}
/// <summary>Verifies that a null parent execution id hides the view-parent-execution button.</summary>
[Fact]
public void Drawer_NullParentExecutionId_HidesViewParentExecutionButton()
{
@@ -272,6 +287,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.DoesNotContain("data-test=\"view-parent-execution\"", cut.Markup);
}
/// <summary>Verifies that a non-null parent execution id shows the view-parent-execution button.</summary>
[Fact]
public void Drawer_NonNullParentExecutionId_ShowsViewParentExecutionButton()
{
@@ -284,6 +300,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.Contains("data-test=\"view-parent-execution\"", cut.Markup);
}
/// <summary>Verifies that view-parent-execution navigates with the parent execution id in the query string.</summary>
[Fact]
public void ViewParentExecution_Navigates_WithExecutionIdQueryString()
{
@@ -303,6 +320,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.Contains($"/audit/log?executionId={parent}", nav.Uri);
}
/// <summary>Verifies that a null execution id hides the view-execution-chain button.</summary>
[Fact]
public void Drawer_NullExecutionId_HidesViewExecutionChainButton()
{
@@ -315,6 +333,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.DoesNotContain("data-test=\"view-execution-chain\"", cut.Markup);
}
/// <summary>Verifies that a non-null execution id shows the view-execution-chain button.</summary>
[Fact]
public void Drawer_NonNullExecutionId_ShowsViewExecutionChainButton()
{
@@ -327,6 +346,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.Contains("data-test=\"view-execution-chain\"", cut.Markup);
}
/// <summary>Verifies that view-execution-chain navigates to the execution tree page.</summary>
[Fact]
public void ViewExecutionChain_Navigates_ToExecutionTreePage()
{
@@ -345,6 +365,8 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.Contains($"/audit/execution-tree?executionId={exec}", nav.Uri);
}
/// <summary>Verifies that copy-as-cURL invokes the clipboard with the cURL command string.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CopyAsCurl_InvokesClipboard_WithCurlString()
{
@@ -10,8 +10,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="AuditEventDetail"/> — the reusable single-row
/// detail body extracted from <see cref="AuditDrilldownDrawer"/> (Task 1 of the
/// Execution-Tree Node Detail Modal feature).
/// detail body extracted from <see cref="AuditDrilldownDrawer"/> for reuse by the
/// Execution-Tree Node Detail Modal.
///
/// These tests render the detail component directly (not via the drawer) and
/// pin the contract the drawer — and any future modal host — relies on:
@@ -22,6 +22,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
/// </summary>
public class AuditEventDetailTests : BunitContext
{
/// <summary>Initializes the test context with loose JS interop so unrelated tests do not fail on the clipboard call.</summary>
public AuditEventDetailTests()
{
// Loose so the cURL clipboard call does not blow up tests that do not
@@ -67,6 +68,7 @@ public class AuditEventDetailTests : BunitContext
Extra = extra,
};
/// <summary>Verifies that the read-only field block renders the audit event fields.</summary>
[Fact]
public void RendersFieldBlock()
{
@@ -81,6 +83,7 @@ public class AuditEventDetailTests : BunitContext
Assert.Contains("2026-05-20T12:30:45", cut.Markup);
}
/// <summary>Verifies that the source node field renders between the site and instance fields.</summary>
[Fact]
public void RendersSourceNodeField_BetweenSiteAndInstance()
{
@@ -103,6 +106,7 @@ public class AuditEventDetailTests : BunitContext
$"Expected SourceSiteId < SourceNode < SourceInstanceId; got {siteIdx}, {nodeIdx}, {instanceIdx}");
}
/// <summary>Verifies that a null source node renders as a dash.</summary>
[Fact]
public void RendersSourceNodeField_AsDashWhenNull()
{
@@ -119,6 +123,7 @@ public class AuditEventDetailTests : BunitContext
Assert.Contains(">—<", cut.Markup);
}
/// <summary>Verifies that the error section renders when the event carries an error.</summary>
[Fact]
public void ErrorSection_RendersWhenErrorPresent()
{
@@ -134,6 +139,7 @@ public class AuditEventDetailTests : BunitContext
Assert.Contains("stack trace here", cut.Markup);
}
/// <summary>Verifies that the error section is hidden when the event has no error.</summary>
[Fact]
public void ErrorSection_HiddenWhenNoError()
{
@@ -144,6 +150,7 @@ public class AuditEventDetailTests : BunitContext
Assert.DoesNotContain("data-test=\"section-error\"", cut.Markup);
}
/// <summary>Verifies that the request section pretty-prints a JSON payload.</summary>
[Fact]
public void RequestSection_PrettyPrintsJson()
{
@@ -156,6 +163,7 @@ public class AuditEventDetailTests : BunitContext
Assert.Matches(@"\n\s+""a"":\s*1", cut.Markup);
}
/// <summary>Verifies that the response section renders when a response payload is present.</summary>
[Fact]
public void ResponseSection_RendersWhenPresent()
{
@@ -167,6 +175,7 @@ public class AuditEventDetailTests : BunitContext
Assert.Contains("data-test=\"response-body\"", cut.Markup);
}
/// <summary>Verifies that the extra section renders when extra data is present.</summary>
[Fact]
public void ExtraSection_RendersWhenPresent()
{
@@ -177,6 +186,7 @@ public class AuditEventDetailTests : BunitContext
Assert.Contains("data-test=\"section-extra\"", cut.Markup);
}
/// <summary>Verifies that a redacted body shows the redaction badge.</summary>
[Fact]
public void RedactedBody_ShowsRedactionBadge()
{
@@ -187,6 +197,7 @@ public class AuditEventDetailTests : BunitContext
Assert.Contains("data-test=\"redaction-badge-request\"", cut.Markup);
}
/// <summary>Verifies that a body that was not redacted hides the redaction badge.</summary>
[Fact]
public void NonRedactedBody_HidesRedactionBadge()
{
@@ -197,6 +208,7 @@ public class AuditEventDetailTests : BunitContext
Assert.DoesNotContain("data-test=\"redaction-badge-request\"", cut.Markup);
}
/// <summary>Verifies that a database outbound event renders its SQL block.</summary>
[Fact]
public void DbOutboundChannel_RendersSqlBlock()
{
@@ -210,6 +222,7 @@ public class AuditEventDetailTests : BunitContext
Assert.Contains("data-test=\"sql-parameters\"", cut.Markup);
}
/// <summary>Verifies that an API channel event shows the copy-as-cURL button.</summary>
[Fact]
public void ApiChannel_ShowsCopyAsCurlButton()
{
@@ -220,6 +233,7 @@ public class AuditEventDetailTests : BunitContext
Assert.Contains("data-test=\"copy-as-curl\"", cut.Markup);
}
/// <summary>Verifies that a non-API channel event hides the copy-as-cURL button.</summary>
[Fact]
public void NonApiChannel_HidesCopyAsCurlButton()
{
@@ -230,6 +244,7 @@ public class AuditEventDetailTests : BunitContext
Assert.DoesNotContain("data-test=\"copy-as-curl\"", cut.Markup);
}
/// <summary>Verifies that a null correlation id hides the show-all-for-operation button.</summary>
[Fact]
public void NullCorrelationId_HidesShowAllButton()
{
@@ -240,6 +255,7 @@ public class AuditEventDetailTests : BunitContext
Assert.DoesNotContain("data-test=\"show-all-events\"", cut.Markup);
}
/// <summary>Verifies that a non-null correlation id shows the show-all-for-operation button.</summary>
[Fact]
public void NonNullCorrelationId_ShowsShowAllButton()
{
@@ -250,6 +266,7 @@ public class AuditEventDetailTests : BunitContext
Assert.Contains("data-test=\"show-all-events\"", cut.Markup);
}
/// <summary>Verifies that the execution buttons render only when the matching execution ids are present.</summary>
[Fact]
public void ExecutionButtons_ConditionalOnExecutionIds()
{
@@ -264,6 +281,7 @@ public class AuditEventDetailTests : BunitContext
Assert.Contains("data-test=\"view-execution-chain\"", cut.Markup);
}
/// <summary>Verifies that show-all-for-operation navigates with the correlation id in the query string.</summary>
[Fact]
public void ShowAllForOperation_Navigates_WithCorrelationIdQueryString()
{
@@ -278,6 +296,7 @@ public class AuditEventDetailTests : BunitContext
Assert.Contains($"/audit/log?correlationId={corr}", nav.Uri);
}
/// <summary>Verifies that view-execution-chain navigates to the execution tree page.</summary>
[Fact]
public void ViewExecutionChain_Navigates_ToExecutionTreePage()
{
@@ -292,6 +311,8 @@ public class AuditEventDetailTests : BunitContext
Assert.Contains($"/audit/execution-tree?executionId={exec}", nav.Uri);
}
/// <summary>Verifies that copy-as-cURL invokes the clipboard with the generated cURL command.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CopyAsCurl_InvokesClipboard_WithCurlString()
{
@@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="AuditFilterBar"/> (#23 M7-T2 / Bundle B).
/// bUnit tests for <see cref="AuditFilterBar"/>.
///
/// The bar carries the 10 spec filter elements plus the Errors-only toggle.
/// Channel is a single-select <c>&lt;select data-test="filter-channel-select"&gt;</c>;
@@ -33,6 +33,7 @@ public class AuditFilterBarTests : BunitContext
private readonly ISiteRepository _siteRepo;
private readonly IAuditLogQueryService _auditLogQueryService;
/// <summary>Initializes the bUnit context with substitute site and audit query services.</summary>
public AuditFilterBarTests()
{
_siteRepo = Substitute.For<ISiteRepository>();
@@ -44,7 +45,7 @@ public class AuditFilterBarTests : BunitContext
}));
Services.AddSingleton(_siteRepo);
// Task 15: the Node multi-select pulls its options from
// The Node multi-select pulls its options from
// IAuditLogQueryService.GetDistinctSourceNodesAsync. The default stub
// returns the two central nodes the cluster uses; individual tests can
// override via _auditLogQueryService.GetDistinctSourceNodesAsync(...).Returns(...).
@@ -56,6 +57,7 @@ public class AuditFilterBarTests : BunitContext
Services.AddSingleton(_auditLogQueryService);
}
/// <summary>Verifies that the filter bar renders all ten filter elements plus the errors-only toggle.</summary>
[Fact]
public void Render_AllTenElements_Plus_ErrorsOnlyToggle_Present()
{
@@ -86,6 +88,7 @@ public class AuditFilterBarTests : BunitContext
}
}
/// <summary>Verifies that applying the filters raises the filter-changed callback with the selected filters.</summary>
[Fact]
public void Apply_RaisesOnFilterChanged_WithSelectedFilters()
{
@@ -103,6 +106,7 @@ public class AuditFilterBarTests : BunitContext
Assert.Equal("Plant-A-OPC", captured.Target);
}
/// <summary>Verifies that choosing a different channel replaces the previous selection because the channel is single-select.</summary>
[Fact]
public void ChangingChannel_ReplacesTheSelection_SingleSelect()
{
@@ -125,6 +129,7 @@ public class AuditFilterBarTests : BunitContext
Assert.Null(captured!.Channels);
}
/// <summary>Verifies that selecting a channel narrows the available kind options.</summary>
[Fact]
public void Channel_Narrows_Kind_Options_When_Selected()
{
@@ -149,6 +154,7 @@ public class AuditFilterBarTests : BunitContext
Assert.DoesNotContain($"data-test=\"filter-kind-ms-opt-{AuditKind.InboundRequest}\"", cut.Markup);
}
/// <summary>Verifies that the errors-only toggle adds the failed, parked and discarded statuses when no status is selected.</summary>
[Fact]
public void ErrorsOnly_Toggle_Adds_FailedParkedDiscarded_ToStatus_WhenStatusIsEmpty()
{
@@ -161,7 +167,7 @@ public class AuditFilterBarTests : BunitContext
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
// Task 9: Errors-only targets the full non-success set {Failed, Parked, Discarded}.
// Errors-only targets the full non-success set {Failed, Parked, Discarded}.
Assert.NotNull(captured!.Statuses);
Assert.Equal(3, captured.Statuses!.Count);
Assert.Contains(AuditStatus.Failed, captured.Statuses);
@@ -175,10 +181,11 @@ public class AuditFilterBarTests : BunitContext
Assert.Equal(new[] { AuditStatus.Delivered }, captured!.Statuses);
}
/// <summary>Verifies that the node multi-select renders its options from the query service and maps the selection through to the filter.</summary>
[Fact]
public void NodeMultiSelect_RendersOptions_FromQueryService_AndMapsThroughToFilter()
{
// Task 15: the Node filter pulls its option set from
// The Node filter pulls its option set from
// IAuditLogQueryService.GetDistinctSourceNodesAsync and threads the
// chip selection into AuditLogQueryFilter.SourceNodes.
AuditLogQueryFilter? captured = null;
@@ -199,10 +206,11 @@ public class AuditFilterBarTests : BunitContext
Assert.Equal(new[] { "central-a" }, captured.SourceNodes);
}
/// <summary>Verifies that applying with several status chips selected passes every selected status to the filter.</summary>
[Fact]
public void Apply_WithMultipleStatusChips_PassesAllSelectedStatuses()
{
// Task 9: multiple explicit Status chips all reach the filter — and they
// Multiple explicit Status chips all reach the filter — and they
// win over the Errors-only default.
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
@@ -219,6 +227,7 @@ public class AuditFilterBarTests : BunitContext
Assert.Contains(AuditStatus.Failed, captured.Statuses);
}
/// <summary>Verifies that a pasted execution id is mapped through to the filter on apply.</summary>
[Fact]
public void Apply_WithPastedExecutionId_MapsThroughToFilter()
{
@@ -236,6 +245,7 @@ public class AuditFilterBarTests : BunitContext
Assert.Equal(executionId, captured!.ExecutionId);
}
/// <summary>Verifies that a blank or unparseable execution id leaves the filter execution id null.</summary>
[Fact]
public void Apply_WithBlankOrUnparseableExecutionId_LeavesFilterExecutionIdNull()
{
@@ -255,6 +265,7 @@ public class AuditFilterBarTests : BunitContext
Assert.Null(captured!.ExecutionId);
}
/// <summary>Verifies that a pasted parent execution id is mapped through to the filter on apply.</summary>
[Fact]
public void Apply_WithPastedParentExecutionId_MapsThroughToFilter()
{
@@ -272,6 +283,7 @@ public class AuditFilterBarTests : BunitContext
Assert.Equal(parentExecutionId, captured!.ParentExecutionId);
}
/// <summary>Verifies that a blank or unparseable parent execution id leaves the filter parent execution id null.</summary>
[Fact]
public void Apply_WithBlankOrUnparseableParentExecutionId_LeavesFilterParentExecutionIdNull()
{
@@ -291,6 +303,7 @@ public class AuditFilterBarTests : BunitContext
Assert.Null(captured!.ParentExecutionId);
}
/// <summary>Verifies that choosing the last-hour time range sets the from-UTC value to approximately one hour ago.</summary>
[Fact]
public void TimeRange_LastHour_PopulatesFromUtc_ApproxOneHourAgo()
{
@@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="AuditResultsGrid"/> (#23 M7-T3 / Bundle B). The grid
/// bUnit tests for <see cref="AuditResultsGrid"/>. The grid
/// renders 10 columns, paginates via keyset (passing the last row's
/// (OccurredAtUtc, EventId) back to the service), raises a row-click callback
/// that Bundle C wires to the drilldown drawer, and styles non-success status
@@ -39,6 +39,7 @@ public class AuditResultsGridTests : BunitContext
ErrorMessage = status == AuditStatus.Failed ? "boom — unreachable" : null,
};
/// <summary>Initializes a new instance of the <see cref="AuditResultsGridTests"/> class.</summary>
public AuditResultsGridTests()
{
_service = Substitute.For<IAuditLogQueryService>();
@@ -62,6 +63,7 @@ public class AuditResultsGridTests : BunitContext
});
}
/// <summary>Verifies the case: render, ten Columns, from Stub Service.</summary>
[Fact]
public void Render_TenColumns_FromStubService()
{
@@ -84,6 +86,7 @@ public class AuditResultsGridTests : BunitContext
}
}
/// <summary>Verifies the case: click, next Page, calls Service, with Cursor, of Last Row.</summary>
[Fact]
public void Click_NextPage_CallsService_WithCursor_OfLastRow()
{
@@ -105,6 +108,7 @@ public class AuditResultsGridTests : BunitContext
Assert.Equal(second.EventId, nextCall.Paging.AfterEventId);
}
/// <summary>Verifies the case: click, row, raises On Row Selected.</summary>
[Fact]
public void Click_Row_RaisesOnRowSelected()
{
@@ -122,10 +126,11 @@ public class AuditResultsGridTests : BunitContext
Assert.Equal(target.EventId, captured!.EventId);
}
/// <summary>Verifies the case: render, includes Node Column, between Site And Channel.</summary>
[Fact]
public void Render_IncludesNodeColumn_BetweenSiteAndChannel()
{
// Task 15: the grid surfaces SourceNode in a dedicated "Node" column
// The grid surfaces SourceNode in a dedicated "Node" column
// positioned between Site and Channel.
StubPage(new List<AuditEventView>
{
@@ -144,6 +149,7 @@ public class AuditResultsGridTests : BunitContext
Assert.True(nodeIdx < channelIdx, "Node column must precede Channel.");
}
/// <summary>Verifies the case: render, includes Execution Id Column.</summary>
[Fact]
public void Render_IncludesExecutionIdColumn()
{
@@ -158,6 +164,7 @@ public class AuditResultsGridTests : BunitContext
Assert.Contains("data-test=\"col-header-ExecutionId\"", cut.Markup);
}
/// <summary>Verifies the case: execution Id, non Null Row, renders Short Monospace Value.</summary>
[Fact]
public void ExecutionId_NonNullRow_RendersShortMonospaceValue()
{
@@ -175,6 +182,7 @@ public class AuditResultsGridTests : BunitContext
Assert.Equal(executionId.ToString(), cell.GetAttribute("title"));
}
/// <summary>Verifies the case: execution Id, null Row, renders Blank Placeholder, no Execution Id Cell.</summary>
[Fact]
public void ExecutionId_NullRow_RendersBlankPlaceholder_NoExecutionIdCell()
{
@@ -187,6 +195,7 @@ public class AuditResultsGridTests : BunitContext
Assert.Empty(cut.FindAll($"[data-test=\"execution-id-{row.EventId}\"]"));
}
/// <summary>Verifies the case: render, includes Parent Execution Id Column.</summary>
[Fact]
public void Render_IncludesParentExecutionIdColumn()
{
@@ -201,6 +210,7 @@ public class AuditResultsGridTests : BunitContext
Assert.Contains("data-test=\"col-header-ParentExecutionId\"", cut.Markup);
}
/// <summary>Verifies the case: parent Execution Id, non Null Row, renders Short Monospace Value.</summary>
[Fact]
public void ParentExecutionId_NonNullRow_RendersShortMonospaceValue()
{
@@ -218,6 +228,7 @@ public class AuditResultsGridTests : BunitContext
Assert.Equal(parentExecutionId.ToString(), cell.GetAttribute("title"));
}
/// <summary>Verifies the case: parent Execution Id, null Row, renders Blank Placeholder, no Parent Execution Id Cell.</summary>
[Fact]
public void ParentExecutionId_NullRow_RendersBlankPlaceholder_NoParentExecutionIdCell()
{
@@ -230,6 +241,7 @@ public class AuditResultsGridTests : BunitContext
Assert.Empty(cut.FindAll($"[data-test=\"parent-execution-id-{row.EventId}\"]"));
}
/// <summary>Verifies the case: status, failed Row, has Error Badge Class.</summary>
[Fact]
public void Status_FailedRow_HasErrorBadgeClass()
{
@@ -247,7 +259,7 @@ public class AuditResultsGridTests : BunitContext
Assert.Contains("bg-success", deliveredBadge.GetAttribute("class") ?? string.Empty);
}
// --- column resize + reorder UX (#23 follow-ups Task 10) ---------------
// --- column resize + reorder UX ---------------
//
// The drag interaction itself is browser-side (audit-grid.js) and covered
// by the Playwright suite. The bUnit tests below exercise the .NET-side
@@ -265,6 +277,7 @@ public class AuditResultsGridTests : BunitContext
private static int HeaderIndex(string markup, string key)
=> markup.IndexOf($"data-col-key=\"{key}\"", StringComparison.Ordinal);
/// <summary>Verifies the case: headers, render Resize Handle And Drag Key, for Every Column.</summary>
[Fact]
public void Headers_RenderResizeHandleAndDragKey_ForEveryColumn()
{
@@ -280,6 +293,7 @@ public class AuditResultsGridTests : BunitContext
}
}
/// <summary>Verifies the case: column Order Parameter, drives Header Order.</summary>
[Fact]
public void ColumnOrderParameter_DrivesHeaderOrder()
{
@@ -300,6 +314,8 @@ public class AuditResultsGridTests : BunitContext
}
}
/// <summary>Verifies the case: on Column Reordered, moves Column Into Target Slot, and Persists.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OnColumnReordered_MovesColumnIntoTargetSlot_AndPersists()
{
@@ -318,6 +334,8 @@ public class AuditResultsGridTests : BunitContext
Assert.Contains("Status", (string)save.Arguments[1]!);
}
/// <summary>Verifies the case: on Column Resized, below Minimum, clamps To64px, and Persists.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OnColumnResized_BelowMinimum_ClampsTo64px_AndPersists()
{
@@ -335,6 +353,7 @@ public class AuditResultsGridTests : BunitContext
i => i.Identifier == "auditGrid.save" && (string)i.Arguments[0]! == "columnWidths");
}
/// <summary>Verifies the case: stored Order, with Unknown Key, degrades Gracefully.</summary>
[Fact]
public void StoredOrder_WithUnknownKey_DegradesGracefully()
{
@@ -360,6 +379,7 @@ public class AuditResultsGridTests : BunitContext
}
}
/// <summary>Verifies the case: stored Widths, for Unknown Column, are Ignored.</summary>
[Fact]
public void StoredWidths_ForUnknownColumn_AreIgnored()
{
@@ -11,7 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="ExecutionDetailModal"/> (Execution-Tree Node Detail
/// Modal, Task 3). The modal opens on an execution-tree node double-click: given
/// Modal). The modal opens on an execution-tree node double-click: given
/// an <c>ExecutionId</c> it loads that execution's audit rows via
/// <see cref="IAuditLogQueryService"/> and shows a list → per-row detail.
///
@@ -24,6 +24,7 @@ public class ExecutionDetailModalTests : BunitContext
private readonly IAuditLogQueryService _service;
private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new();
/// <summary>Initializes a new instance of the <c>ExecutionDetailModalTests</c> class.</summary>
public ExecutionDetailModalTests()
{
_service = Substitute.For<IAuditLogQueryService>();
@@ -66,6 +67,7 @@ public class ExecutionDetailModalTests : BunitContext
});
}
/// <summary>Verifies that closed modal renders nothing and does not query.</summary>
[Fact]
public void ClosedModal_RendersNothing_AndDoesNotQuery()
{
@@ -79,6 +81,7 @@ public class ExecutionDetailModalTests : BunitContext
Assert.Empty(_calls);
}
/// <summary>Verifies that open transition queries by execution id with page size 100.</summary>
[Fact]
public void OpenTransition_QueriesByExecutionId_WithPageSize100()
{
@@ -101,6 +104,7 @@ public class ExecutionDetailModalTests : BunitContext
Assert.Equal(100, _calls[0].Paging!.PageSize);
}
/// <summary>Verifies that still open non open parameter change does not requery.</summary>
[Fact]
public void StillOpen_NonOpenParameterChange_DoesNotRequery()
{
@@ -119,6 +123,7 @@ public class ExecutionDetailModalTests : BunitContext
Assert.Single(_calls);
}
/// <summary>Verifies that multi row renders list view with one button per row.</summary>
[Fact]
public void MultiRow_RendersListView_WithOneButtonPerRow()
{
@@ -138,6 +143,7 @@ public class ExecutionDetailModalTests : BunitContext
Assert.Empty(cut.FindAll("[data-test=\"drawer-fields\"]"));
}
/// <summary>Verifies that multi row click row shows audit event detail.</summary>
[Fact]
public void MultiRow_ClickRow_ShowsAuditEventDetail()
{
@@ -158,6 +164,7 @@ public class ExecutionDetailModalTests : BunitContext
Assert.NotNull(cut.Find("[data-test=\"execution-detail-back\"]"));
}
/// <summary>Verifies that multi row back control returns to list.</summary>
[Fact]
public void MultiRow_BackControl_ReturnsToList()
{
@@ -180,6 +187,7 @@ public class ExecutionDetailModalTests : BunitContext
Assert.Empty(cut.FindAll("[data-test=\"drawer-fields\"]"));
}
/// <summary>Verifies that single row opens straight to detail no back control.</summary>
[Fact]
public void SingleRow_OpensStraightToDetail_NoBackControl()
{
@@ -197,6 +205,7 @@ public class ExecutionDetailModalTests : BunitContext
Assert.Empty(cut.FindAll("[data-test=\"execution-detail-back\"]"));
}
/// <summary>Verifies that zero row shows friendly empty state.</summary>
[Fact]
public void ZeroRow_ShowsFriendlyEmptyState()
{
@@ -211,6 +220,7 @@ public class ExecutionDetailModalTests : BunitContext
Assert.Contains("This execution emitted no audit rows.", empty.TextContent);
}
/// <summary>Verifies that query throws shows inline error state does not rethrow.</summary>
[Fact]
public void QueryThrows_ShowsInlineErrorState_DoesNotRethrow()
{
@@ -228,6 +238,7 @@ public class ExecutionDetailModalTests : BunitContext
Assert.Contains("db is down", error.TextContent);
}
/// <summary>Verifies that close button raises on close.</summary>
[Fact]
public void CloseButton_RaisesOnClose()
{
@@ -245,6 +256,7 @@ public class ExecutionDetailModalTests : BunitContext
Assert.True(closed);
}
/// <summary>Verifies that backdrop click raises on close.</summary>
[Fact]
public void BackdropClick_RaisesOnClose()
{
@@ -262,6 +274,7 @@ public class ExecutionDetailModalTests : BunitContext
Assert.True(closed);
}
/// <summary>Verifies that escape key raises on close.</summary>
[Fact]
public void EscapeKey_RaisesOnClose()
{
@@ -279,6 +292,7 @@ public class ExecutionDetailModalTests : BunitContext
Assert.True(closed);
}
/// <summary>Verifies that header shows short execution id and row count.</summary>
[Fact]
public void Header_ShowsShortExecutionId_AndRowCount()
{
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="ExecutionTree"/> (Audit Log ParentExecutionId
/// feature, Task 10). The component takes the FLAT
/// feature). The component takes the FLAT
/// <see cref="ExecutionTreeNode"/> list the repository returns, assembles it
/// into a tree by joining <see cref="ExecutionTreeNode.ParentExecutionId"/> to a
/// parent node's <see cref="ExecutionTreeNode.ExecutionId"/>, and renders it
@@ -35,6 +35,7 @@ public class ExecutionTreeTests : BunitContext
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc));
/// <summary>Verifies that a single node renders as one tree node.</summary>
[Fact]
public void SingleNode_RendersOneTreeNode()
{
@@ -48,6 +49,7 @@ public class ExecutionTreeTests : BunitContext
Assert.Contains($"data-test=\"tree-node-{id}\"", cut.Markup);
}
/// <summary>Verifies that a multi-level tree is assembled from the flat node list.</summary>
[Fact]
public void MultiLevel_AssemblesTree_FromFlatList()
{
@@ -82,6 +84,7 @@ public class ExecutionTreeTests : BunitContext
Assert.True(childIdx < grandIdx, "child must render before grandchild");
}
/// <summary>Verifies that a stub node renders with a stub marker.</summary>
[Fact]
public void StubNode_RendersStubMarker()
{
@@ -104,6 +107,7 @@ public class ExecutionTreeTests : BunitContext
Assert.Contains("no audited actions", cut.Markup);
}
/// <summary>Verifies that the node the user arrived from is visually highlighted.</summary>
[Fact]
public void ArrivedFromNode_IsVisuallyHighlighted()
{
@@ -128,6 +132,7 @@ public class ExecutionTreeTests : BunitContext
Assert.DoesNotContain("execution-tree-node--current", other.GetAttribute("class") ?? string.Empty);
}
/// <summary>Verifies that each node link points to the audit log filtered by that node's execution.</summary>
[Fact]
public void NodeLink_PointsTo_AuditLogFilteredByThatExecution()
{
@@ -154,6 +159,7 @@ public class ExecutionTreeTests : BunitContext
Assert.Equal($"/audit/log?executionId={root}", rootLink.GetAttribute("href"));
}
/// <summary>Verifies that an empty node list renders nothing and does not throw.</summary>
[Fact]
public void EmptyNodeList_RendersNothingWithoutThrowing()
{
@@ -164,6 +170,7 @@ public class ExecutionTreeTests : BunitContext
Assert.DoesNotContain("data-test=\"tree-node-", cut.Markup);
}
/// <summary>Verifies that a cyclic flat list is handled without looping forever.</summary>
[Fact]
public void CyclicFlatList_TerminatesWithoutInfiniteLoop()
{
@@ -186,6 +193,7 @@ public class ExecutionTreeTests : BunitContext
Assert.Equal(1, CountOccurrences(cut.Markup, $"data-test=\"tree-node-{b}\""));
}
/// <summary>Verifies that toggling a node collapses its child subtree and toggling again re-expands it.</summary>
[Fact]
public void ToggleExpand_CollapsesAndReExpandsChildSubtree()
{
@@ -229,6 +237,7 @@ public class ExecutionTreeTests : BunitContext
cut.Find($"[data-test=\"tree-toggle-{root}\"]").GetAttribute("aria-expanded"));
}
/// <summary>Verifies that double-clicking a node raises <c>OnNodeActivated</c> with that node's execution id.</summary>
[Fact]
public void DoubleClickingNode_RaisesOnNodeActivated_WithExecutionId()
{
@@ -255,6 +264,7 @@ public class ExecutionTreeTests : BunitContext
Assert.Equal(root, activated);
}
/// <summary>Verifies that double-clicking a nested node bubbles the activation up to the root callback.</summary>
[Fact]
public void DoubleClickingNestedNode_BubblesOnNodeActivated_ToRoot()
{
@@ -16,7 +16,7 @@ using ConnectionCertificates =
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components;
/// <summary>
/// Covers the M7-B10 (T17) connection-certificates management page: an
/// Covers the connection-certificates management page: an
/// Administrator-gated page that resolves a data connection's owning site, lists
/// the site's trusted-peer / rejected certificates via
/// <see cref="ICertManagementService.ListAsync"/>, and removes a certificate via
@@ -31,6 +31,7 @@ public class ConnectionCertificatesTests : BunitContext
private const int ConnectionId = 7;
private const string SiteIdentifier = "plant-a";
/// <summary>Initializes the test context and registers the services the page requires.</summary>
public ConnectionCertificatesTests()
{
Services.AddSingleton(_certs);
@@ -84,6 +85,7 @@ public class ConnectionCertificatesTests : BunitContext
return host.FindComponent<ConnectionCertificates>();
}
/// <summary>Verifies that the page lists both certificates from the owning site.</summary>
[Fact]
public void Lists_Two_Certs_From_Owning_Site()
{
@@ -105,6 +107,7 @@ public class ConnectionCertificatesTests : BunitContext
_certs.Received(1).ListAsync(SiteIdentifier, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that removing a certificate calls RemoveAsync with the site identifier and thumbprint.</summary>
[Fact]
public void Remove_Calls_RemoveAsync_With_SiteIdentifier_And_Thumbprint()
{
@@ -119,6 +122,7 @@ public class ConnectionCertificatesTests : BunitContext
_certs.Received(1).RemoveAsync(SiteIdentifier, "AAAA1111", Arg.Any<CancellationToken>());
}
/// <summary>Verifies that an empty certificate store shows the empty-state note.</summary>
[Fact]
public void Empty_Store_Shows_Empty_Note()
{
@@ -131,6 +135,7 @@ public class ConnectionCertificatesTests : BunitContext
Assert.Empty(cut.FindAll("[data-test=cert-row]"));
}
/// <summary>Verifies that a list failure shows a load error.</summary>
[Fact]
public void List_Failure_Shows_Load_Error()
{
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Health;
/// <summary>
/// bUnit tests for <see cref="AuditKpiTiles"/> (#23 M7 Bundle E / M7-T13). The
/// bUnit tests for <see cref="AuditKpiTiles"/>. The
/// component renders three Bootstrap-card tiles — Volume, Error Rate, Backlog —
/// from a single <see cref="AuditLogKpiSnapshot"/>. The tests pin:
///
@@ -25,6 +25,7 @@ public class AuditKpiTilesTests : BunitContext
private static AuditLogKpiSnapshot MakeSnapshot(long total, long errors, long backlog) =>
new(total, errors, backlog, new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc));
/// <summary>Verifies that three tiles are rendered from the snapshot.</summary>
[Fact]
public void Renders_ThreeTiles_FromSnapshot()
{
@@ -43,6 +44,7 @@ public class AuditKpiTilesTests : BunitContext
Assert.Contains("7", cut.Markup); // backlog
}
/// <summary>Verifies that the error rate is computed from the total and error counts.</summary>
[Fact]
public void ErrorRate_Computed_From_Total_AndErrors()
{
@@ -54,6 +56,7 @@ public class AuditKpiTilesTests : BunitContext
Assert.Contains("5.0%", cut.Markup);
}
/// <summary>Verifies that zero events do not cause a division by zero and render a zero percent error rate.</summary>
[Fact]
public void ZeroEvents_DoesNotDivideByZero_RendersZeroPercent()
{
@@ -70,6 +73,7 @@ public class AuditKpiTilesTests : BunitContext
Assert.Contains("data-test=\"audit-kpi-volume\"", cut.Markup);
}
/// <summary>Verifies that an unavailable snapshot renders em dashes and an error message.</summary>
[Fact]
public void UnavailableSnapshot_RendersEmDashes_AndErrorMessage()
{
@@ -85,6 +89,7 @@ public class AuditKpiTilesTests : BunitContext
Assert.Contains("DB connection refused", cut.Markup);
}
/// <summary>Verifies that clicking the error rate tile navigates to the audit log filtered by failed status.</summary>
[Fact]
public void ErrorRateTile_Click_NavigatesToAuditLog_WithFailedStatusFilter()
{
@@ -102,6 +107,7 @@ public class AuditKpiTilesTests : BunitContext
Assert.Contains("/audit/log?status=Failed", nav.Uri);
}
/// <summary>Verifies that clicking the volume tile navigates to the unfiltered audit log.</summary>
[Fact]
public void VolumeTile_Click_NavigatesToUnfilteredAuditLog()
{
@@ -117,6 +123,7 @@ public class AuditKpiTilesTests : BunitContext
Assert.EndsWith("/audit/log", nav.Uri);
}
/// <summary>Verifies that clicking the backlog tile navigates to the audit log.</summary>
[Fact]
public void BacklogTile_Click_NavigatesToAuditLog()
{
@@ -131,6 +138,7 @@ public class AuditKpiTilesTests : BunitContext
Assert.EndsWith("/audit/log", nav.Uri);
}
/// <summary>Verifies that a nonzero error rate below ten percent gets a warning border rather than a danger border.</summary>
[Fact]
public void NonzeroErrorRate_GetsWarningBorder_NotDangerBelowTenPercent()
{
@@ -144,6 +152,7 @@ public class AuditKpiTilesTests : BunitContext
Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
/// <summary>Verifies that a high error rate gets a danger border.</summary>
[Fact]
public void HighErrorRate_GetsDangerBorder()
{
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Health;
/// <summary>
/// bUnit tests for <see cref="SiteCallKpiTiles"/> (Site Call Audit #22, Task 7).
/// bUnit tests for <see cref="SiteCallKpiTiles"/> .
/// The component renders three Bootstrap-card tiles — Buffered, Stuck, Parked —
/// from a single <see cref="SiteCallKpiResponse"/> snapshot. The tests pin:
///
@@ -35,6 +35,7 @@ public class SiteCallKpiTilesTests : BunitContext
OldestPendingAge: null,
StuckCount: stuck);
/// <summary>Verifies that the component renders three tiles from a snapshot.</summary>
[Fact]
public void Renders_ThreeTiles_FromSnapshot()
{
@@ -54,6 +55,7 @@ public class SiteCallKpiTilesTests : BunitContext
Assert.Contains(">3<", cut.Markup); // parked
}
/// <summary>Verifies that an unavailable snapshot renders em dashes and the error message.</summary>
[Fact]
public void UnavailableSnapshot_RendersEmDashes_AndErrorMessage()
{
@@ -69,6 +71,7 @@ public class SiteCallKpiTilesTests : BunitContext
Assert.Contains("site call repository unavailable", cut.Markup);
}
/// <summary>Verifies that the parked tile gets a danger border when the parked count is above zero.</summary>
[Fact]
public void ParkedTile_GetsDangerBorder_WhenParkedAboveZero()
{
@@ -80,6 +83,7 @@ public class SiteCallKpiTilesTests : BunitContext
Assert.Contains("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
/// <summary>Verifies that the parked tile has no danger border when the parked count is zero.</summary>
[Fact]
public void ParkedTile_NoDangerBorder_WhenParkedZero()
{
@@ -91,6 +95,7 @@ public class SiteCallKpiTilesTests : BunitContext
Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
/// <summary>Verifies that the stuck tile gets a warning border when the stuck count is above zero.</summary>
[Fact]
public void StuckTile_GetsWarningBorder_WhenStuckAboveZero()
{
@@ -104,6 +109,7 @@ public class SiteCallKpiTilesTests : BunitContext
Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
/// <summary>Verifies that the stuck tile has no warning border when the stuck count is zero.</summary>
[Fact]
public void StuckTile_NoWarningBorder_WhenStuckZero()
{
@@ -115,6 +121,7 @@ public class SiteCallKpiTilesTests : BunitContext
Assert.DoesNotContain("border-warning", tile.GetAttribute("class") ?? string.Empty);
}
/// <summary>Verifies that the buffered tile never gets a threshold border, even with a high count.</summary>
[Fact]
public void BufferedTile_HasNoThresholdBorder_EvenWithHighCount()
{
@@ -130,6 +137,7 @@ public class SiteCallKpiTilesTests : BunitContext
Assert.DoesNotContain("border-warning", cls);
}
/// <summary>Verifies that clicking the buffered tile navigates to the unfiltered site calls report.</summary>
[Fact]
public void BufferedTile_Click_NavigatesToUnfilteredSiteCallsReport()
{
@@ -145,6 +153,7 @@ public class SiteCallKpiTilesTests : BunitContext
Assert.EndsWith("/site-calls/report", nav.Uri);
}
/// <summary>Verifies that clicking the stuck tile navigates to the site calls report with the stuck filter.</summary>
[Fact]
public void StuckTile_Click_NavigatesToSiteCallsReport_WithStuckFilter()
{
@@ -160,6 +169,7 @@ public class SiteCallKpiTilesTests : BunitContext
Assert.Contains("/site-calls/report?stuck=true", nav.Uri);
}
/// <summary>Verifies that clicking the parked tile navigates to the site calls report with the parked status filter.</summary>
[Fact]
public void ParkedTile_Click_NavigatesToSiteCallsReport_WithParkedStatusFilter()
{
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components;
/// <summary>
/// M7-T16: the Instance Configure page accepts a CSV of attribute overrides via
/// The Instance Configure page accepts a CSV of attribute overrides via
/// an <c>&lt;InputFile&gt;</c>, parses it with the shared
/// <see cref="OverrideCsvParser"/>, validates each row against the instance's
/// overridable attributes (name must exist + not be template-locked, value must be
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components;
/// <para>
/// <c>InstanceConfigure</c> is a heavyweight page (≈7 injected services incl.
/// <c>InstanceService</c> + the flattening pipeline), so — consistent with the
/// MV-14 List-override and native-alarm coverage — the parse→validate→build-dict
/// list-override and native-alarm coverage — the parse→validate→build-dict
/// core is extracted to an <c>internal static</c> helper exercised directly here,
/// plus structural assertions over the component source that pin the InputFile +
/// reuse-the-existing-submit-path wiring.
@@ -49,6 +49,7 @@ public class InstanceConfigureCsvImportTests
// ── Core: valid CSV → override dict, no errors ──────────────────────────
/// <summary>Verifies that a valid CSV builds the override dictionary with no errors.</summary>
[Fact]
public void ValidCsv_BuildsOverrideDict_WithNoErrors()
{
@@ -64,6 +65,7 @@ public class InstanceConfigureCsvImportTests
Assert.Equal("Pump-A", outcome.Overrides["Name"]);
}
/// <summary>Verifies that a valid list-valued CSV row builds canonical JSON that round-trips through the value codec.</summary>
[Fact]
public void ValidListCsv_BuildsCanonicalJson_PerCodecRoundTrip()
{
@@ -81,6 +83,7 @@ public class InstanceConfigureCsvImportTests
// ── Core: bad rows → errors, NO dict (all-or-nothing) ───────────────────
/// <summary>Verifies that a row naming an unknown attribute produces an error and applies nothing.</summary>
[Fact]
public void UnknownAttribute_ProducesError_AndAppliesNothing()
{
@@ -94,6 +97,7 @@ public class InstanceConfigureCsvImportTests
Assert.Contains(outcome.Errors, e => e.Contains("DoesNotExist") && e.Contains("Line 2"));
}
/// <summary>Verifies that a template-locked attribute is rejected, matching the manual editor.</summary>
[Fact]
public void TemplateLockedAttribute_IsRejected_LikeTheEditor()
{
@@ -109,6 +113,7 @@ public class InstanceConfigureCsvImportTests
Assert.Contains(outcome.Errors, e => e.Contains("SerialNumber"));
}
/// <summary>Verifies that a type-incompatible value produces an error and applies nothing.</summary>
[Fact]
public void TypeIncompatibleValue_ProducesError_AndAppliesNothing()
{
@@ -123,6 +128,7 @@ public class InstanceConfigureCsvImportTests
Assert.Contains(outcome.Errors, e => e.Contains("Setpoint") && e.Contains("Line 2"));
}
/// <summary>Verifies that malformed list JSON produces an error and applies nothing.</summary>
[Fact]
public void MalformedListJson_ProducesError_AndAppliesNothing()
{
@@ -136,6 +142,7 @@ public class InstanceConfigureCsvImportTests
Assert.Contains(outcome.Errors, e => e.Contains("Counts"));
}
/// <summary>Verifies that parser errors propagate through the helper and nothing is applied.</summary>
[Fact]
public void ParserErrors_PropagateThrough_AndApplyNothing()
{
@@ -153,6 +160,7 @@ public class InstanceConfigureCsvImportTests
// ── Structural: InputFile + reuse-the-existing-submit-path wiring ───────
/// <summary>Verifies that the page source wires up the CSV input file control with its test hooks.</summary>
[Fact]
public void Page_WiresCsvInputFile_WithTestHooks()
{
@@ -165,6 +173,7 @@ public class InstanceConfigureCsvImportTests
Assert.Contains("OverrideCsvParser.Parse", markup);
}
/// <summary>Verifies that the page applies the imported overrides through the existing set-attribute-override path.</summary>
[Fact]
public void Page_AppliesViaExistingSetAttributeOverridePath()
{
@@ -55,6 +55,7 @@ public class InstanceConfigureNativeAlarmCsvImportTests
// ── Core: valid CSV → rows, no errors ───────────────────────────────────
/// <summary>Verifies that a valid CSV builds retarget rows with no errors.</summary>
[Fact]
public void ValidCsv_BuildsRetargetRows_WithNoErrors()
{
@@ -83,6 +84,7 @@ public class InstanceConfigureNativeAlarmCsvImportTests
Assert.Null(pump.Filter);
}
/// <summary>Verifies that a row with all fields blank is marked as a clear.</summary>
[Fact]
public void AllBlankRow_IsMarkedAsClear()
{
@@ -98,6 +100,7 @@ public class InstanceConfigureNativeAlarmCsvImportTests
Assert.True(row.IsClear);
}
/// <summary>Verifies that sources absent from the file are left untouched because the import merges rather than replaces.</summary>
[Fact]
public void SourcesAbsentFromTheFile_AreUntouched_MergeNotReplace()
{
@@ -114,6 +117,7 @@ public class InstanceConfigureNativeAlarmCsvImportTests
// ── Core: bad rows → errors, NO rows (all-or-nothing) ───────────────────
/// <summary>Verifies that an unknown source produces an error and nothing is applied.</summary>
[Fact]
public void UnknownSource_ProducesError_AndAppliesNothing()
{
@@ -129,6 +133,7 @@ public class InstanceConfigureNativeAlarmCsvImportTests
Assert.Contains(outcome.Errors, e => e.Contains("DoesNotExist") && e.Contains("Line 2"));
}
/// <summary>Verifies that a template-locked source is rejected, matching the server's behavior.</summary>
[Fact]
public void TemplateLockedSource_IsRejected_LikeTheServer()
{
@@ -142,6 +147,7 @@ public class InstanceConfigureNativeAlarmCsvImportTests
Assert.Contains(outcome.Errors, e => e.Contains("LockedAlarms") && e.Contains("locked"));
}
/// <summary>Verifies that a duplicate source is rejected and nothing is applied.</summary>
[Fact]
public void DuplicateSource_IsRejected_AndAppliesNothing()
{
@@ -157,6 +163,7 @@ public class InstanceConfigureNativeAlarmCsvImportTests
Assert.Contains(outcome.Errors, e => e.Contains("BoilerAlarms") && e.Contains("more than once"));
}
/// <summary>Verifies that parser errors propagate through and nothing is applied.</summary>
[Fact]
public void ParserErrors_PropagateThrough_AndApplyNothing()
{
@@ -174,6 +181,7 @@ public class InstanceConfigureNativeAlarmCsvImportTests
// ── Structural: InputFile + reuse-the-existing-save-path wiring ─────────
/// <summary>Verifies that the page wires the native-alarm-source CSV input file with its test hooks.</summary>
[Fact]
public void Page_WiresNativeAlarmSourceCsvInputFile_WithTestHooks()
{
@@ -186,6 +194,7 @@ public class InstanceConfigureNativeAlarmCsvImportTests
Assert.Contains("BuildNativeAlarmSourceCsvImport", markup);
}
/// <summary>Verifies that the import applies changes through the existing repository upsert path.</summary>
[Fact]
public void Import_AppliesViaTheExistingRepositoryUpsertPath()
{
@@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components;
/// <summary>
/// Covers the M7-B6 (T15/T16) additions to <c>NodeBrowserDialog</c>: the
/// Covers the search additions to <c>NodeBrowserDialog</c>: the
/// address-space search box that renders <see cref="IBrowseService.SearchAsync"/>
/// matches as a flat selectable list, and that selecting a search result feeds
/// the SAME selection mechanism the tree uses (so the dialog's existing
@@ -20,6 +20,7 @@ public class NodeBrowserDialogSearchTests : BunitContext
{
private readonly IBrowseService _browse = Substitute.For<IBrowseService>();
/// <summary>Initializes the test context with a substituted browse service that returns an empty root.</summary>
public NodeBrowserDialogSearchTests()
{
Services.AddSingleton(_browse);
@@ -63,6 +64,7 @@ public class NodeBrowserDialogSearchTests : BunitContext
private Func<string?> _capturedSelection = () => null;
/// <summary>Verifies that search matches render as rows carrying the expected data-test hooks.</summary>
[Fact]
public void Search_RendersMatchRows_WithDataTestHooks()
{
@@ -84,6 +86,7 @@ public class NodeBrowserDialogSearchTests : BunitContext
Assert.Contains("Double", cut.Markup);
}
/// <summary>Verifies that clicking a search result raises the selection callback with the node id.</summary>
[Fact]
public void ClickingSearchResult_RaisesSelectionCallback_WithNodeId()
{
@@ -106,6 +109,7 @@ public class NodeBrowserDialogSearchTests : BunitContext
Assert.Equal("ns=2;s=Pump1.Speed", _capturedSelection());
}
/// <summary>Verifies that submitting a blank query clears the displayed search results.</summary>
[Fact]
public void BlankQuery_ClearsResults()
{
@@ -125,6 +129,7 @@ public class NodeBrowserDialogSearchTests : BunitContext
Assert.Empty(cut.FindAll("[data-test=node-search-result]"));
}
/// <summary>Verifies that a blank query after a failed search clears the stale failure alert.</summary>
[Fact]
public void BlankQueryAfterFailure_ClearsStaleFailureAlert()
{
@@ -13,6 +13,7 @@ public class NodeBrowserDialogSelectionTests : BunitContext
{
private readonly IBrowseService _browse = Substitute.For<IBrowseService>();
/// <summary>Initializes the test context with a substituted browse service.</summary>
public NodeBrowserDialogSelectionTests()
{
Services.AddSingleton(_browse);
@@ -36,6 +37,7 @@ public class NodeBrowserDialogSelectionTests : BunitContext
CapReached: false, Failure: null));
}
/// <summary>Verifies that confirming a selection emits the full node including its data type through the node-selected callback.</summary>
[Fact]
public void Confirm_emits_full_node_with_datatype_on_OnNodeSelected()
{
@@ -57,6 +59,7 @@ public class NodeBrowserDialogSelectionTests : BunitContext
Assert.Equal("Double", captured.DataType);
}
/// <summary>Verifies that setting ShowSearch to false hides the search box.</summary>
[Fact]
public void ShowSearch_false_hides_search_box()
{
@@ -74,6 +77,7 @@ public class NodeBrowserDialogSelectionTests : BunitContext
// ONLY user-reachable selection path in production is the tree (Select(TreeNode)
// → _selectedNode = node.Source). This guards that path end-to-end: a tree-node
// click must carry the node's DataType through Confirm → OnNodeSelected.
/// <summary>Verifies that selecting a node in the tree emits the full node including its data type through the node-selected callback.</summary>
[Fact]
public void TreeSelect_emits_full_node_with_datatype_on_OnNodeSelected()
{
@@ -107,6 +111,7 @@ public class NodeBrowserDialogSelectionTests : BunitContext
// SelectableNodeClasses (design 2026-08-21): the create page needs to pick a
// module's CONTAINER object, not just a leaf tag. Default stays Variable-only
// so every existing caller is unaffected.
/// <summary>Verifies that an Object node is not selectable by default.</summary>
[Fact]
public void Object_node_is_not_selectable_by_default()
{
@@ -122,6 +127,7 @@ public class NodeBrowserDialogSelectionTests : BunitContext
Assert.Single(cut.FindAll("[data-test=node-search-result] button.btn-link"));
}
/// <summary>Verifies that an Object node is selectable when its class is listed as selectable.</summary>
[Fact]
public void Object_node_is_selectable_when_listed()
{
@@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components;
/// <summary>
/// Covers the M7-B8 (T17) "Verify endpoint" affordance added to
/// Covers the "Verify endpoint" affordance added to
/// <see cref="OpcUaEndpointEditor"/>: a button that calls the injected
/// <see cref="IEndpointVerificationService"/> with the current endpoint config and
/// renders the typed <see cref="VerifyEndpointResult"/> — green on success, a red
@@ -21,7 +21,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components;
/// <see cref="VerifyFailureKind.UntrustedCertificate"/> with a captured cert.
///
/// <para>
/// Also covers the M7-B10 (T17) "Trust certificate" affordance on the untrusted-cert
/// Also covers the "Trust certificate" affordance on the untrusted-cert
/// panel: an Administrator-gated button that calls
/// <see cref="ICertManagementService.TrustAsync"/> and re-runs Verify on success.
/// </para>
@@ -34,6 +34,7 @@ public class OpcUaEndpointVerifyTests : BunitContext
private readonly ICertManagementService _certs =
Substitute.For<ICertManagementService>();
/// <summary>Registers the substitute verification and certificate-management services with the test container.</summary>
public OpcUaEndpointVerifyTests()
{
Services.AddSingleton(_verify);
@@ -86,6 +87,7 @@ public class OpcUaEndpointVerifyTests : BunitContext
return host.FindComponent<OpcUaEndpointEditor>();
}
/// <summary>Verifies that a successful endpoint verification shows a success message.</summary>
[Fact]
public void Verify_Success_ShowsSuccessMessage()
{
@@ -102,6 +104,7 @@ public class OpcUaEndpointVerifyTests : BunitContext
Assert.Empty(cut.FindAll("[data-test=verify-cert-panel]"));
}
/// <summary>Verifies that an untrusted server certificate shows the certificate panel with its thumbprint.</summary>
[Fact]
public void Verify_UntrustedCertificate_ShowsCertPanelWithThumbprint()
{
@@ -128,6 +131,7 @@ public class OpcUaEndpointVerifyTests : BunitContext
Assert.NotEmpty(cut.FindAll("[data-test=verify-failure]"));
}
/// <summary>Verifies that a generic verification failure shows a failure message.</summary>
[Fact]
public void Verify_GenericFailure_ShowsFailureMessage()
{
@@ -161,6 +165,7 @@ public class OpcUaEndpointVerifyTests : BunitContext
.Returns(new VerifyEndpointResult(
false, VerifyFailureKind.UntrustedCertificate, "Untrusted certificate.", cert));
/// <summary>Verifies that an administrator sees the trust button and that clicking it calls the trust operation.</summary>
[Fact]
public void TrustButton_Admin_SeesButton_AndClick_CallsTrustAsync()
{
@@ -183,6 +188,7 @@ public class OpcUaEndpointVerifyTests : BunitContext
"plant-a", "PLC-OPC", cert.DerBase64, cert.Thumbprint, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a user with only the Designer role does not see the trust button.</summary>
[Fact]
public void TrustButton_NonAdmin_DesignerOnly_DoesNotSeeButton()
{
@@ -5,6 +5,9 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components;
public class SecuredWriteDataTypeMapperTests
{
/// <summary>Verifies that known Galaxy data type names map to the expected data type.</summary>
/// <param name="galaxy">The Galaxy data type name.</param>
/// <param name="expected">The expected mapped data type.</param>
[Theory]
[InlineData("Boolean", DataType.Boolean)]
[InlineData("bool", DataType.Boolean)]
@@ -22,6 +25,8 @@ public class SecuredWriteDataTypeMapperTests
Assert.Equal(expected, dt);
}
/// <summary>Verifies that unknown, null or blank Galaxy type names fail to map.</summary>
/// <param name="galaxy">The Galaxy data type name, possibly null or blank.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -17,7 +17,7 @@ using SecuredWritesPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Opera
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components;
/// <summary>
/// bUnit rendering + interaction tests for the Secured Writes page (M7 T14b / C5).
/// bUnit rendering + interaction tests for the Secured Writes page.
/// The page is the UI for the two-person MxGateway write workflow: an Operator submits,
/// a different Verifier approves/rejects, and everyone sees the history. The page only
/// SUBMITS commands through <see cref="ISecuredWriteService"/> — the server enforces
@@ -37,6 +37,7 @@ public class SecuredWritesTests : BunitContext
// even though these tests never drive the browser.
private readonly IBrowseService _browse = Substitute.For<IBrowseService>();
/// <summary>Registers the substitute services and default stubs the Secured Writes page needs when it renders.</summary>
public SecuredWritesTests()
{
Services.AddSingleton(_service);
@@ -48,7 +49,7 @@ public class SecuredWritesTests : BunitContext
// BOTH overloads must be stubbed: Reject takes the bool form, Approve takes the
// DialogSeverity form (register row 75). An unstubbed NSubstitute call returns a
// null Task, so missing one here surfaces as an NRE inside the click handler
// rather than as anything that names the dialog — the row-28 gotcha.
// rather than as anything that names the dialog — a known gotcha.
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>())
.Returns(Task.FromResult(true));
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DialogSeverity>(), Arg.Any<string?>())
@@ -130,6 +131,7 @@ public class SecuredWritesTests : BunitContext
// ── (a) Operator: submit form present + Submit calls SubmitAsync with the values ──
/// <summary>Verifies that a user with the Operator role sees the submit form.</summary>
[Fact]
public void Operator_SeesSubmitForm()
{
@@ -145,6 +147,7 @@ public class SecuredWritesTests : BunitContext
});
}
/// <summary>Verifies that submitting as an Operator calls the service with the entered values.</summary>
[Fact]
public void Operator_Submit_CallsServiceWithEnteredValues()
{
@@ -175,6 +178,7 @@ public class SecuredWritesTests : BunitContext
// ── (b) Verifier: pending row renders with Approve/Reject; own-row actions disabled ──
/// <summary>Verifies that a Verifier sees enabled approve and reject actions on a pending row submitted by another operator.</summary>
[Fact]
public void Verifier_PendingRow_HasEnabledActions_ForOtherOperatorsRow()
{
@@ -194,6 +198,7 @@ public class SecuredWritesTests : BunitContext
});
}
/// <summary>Verifies that a Verifier's own submission has its approve and reject actions disabled.</summary>
[Fact]
public void Verifier_OwnSubmission_HasApproveAndRejectDisabled()
{
@@ -216,6 +221,7 @@ public class SecuredWritesTests : BunitContext
});
}
/// <summary>Verifies that approving a pending row as a Verifier calls <c>ApproveAsync</c> on the service.</summary>
[Fact]
public void Verifier_Approve_CallsApproveAsync()
{
@@ -233,6 +239,7 @@ public class SecuredWritesTests : BunitContext
// ── (c) History: terminal rows render ──
/// <summary>Verifies that the history section renders rows in terminal states.</summary>
[Fact]
public void History_RendersTerminalRows()
{
@@ -263,6 +270,7 @@ public class SecuredWritesTests : BunitContext
// ── (d) History pager: page-size 50, driven by TotalCount ──
/// <summary>Verifies that the history pager requests the second page using the total count when Next is clicked.</summary>
[Fact]
public void History_Pager_NextRequestsSecondPage_UsingTotalCount()
{
@@ -285,13 +293,14 @@ public class SecuredWritesTests : BunitContext
cut.Find("[data-test='secured-write-history-next']").Click();
// The history page-2 fetch offsets by one page (skip: 50).
// The history second-page fetch offsets by one page (skip: 50).
_service.Received().ListAsync(
Arg.Any<string?>(), Arg.Any<string?>(), 50, Arg.Any<int>(), Arg.Any<CancellationToken>());
}
// ── (e) Approve dialog surfaces submission age (stale-setpoint scenario) ──
/// <summary>Verifies that the approve confirmation dialog shows the age of the submission.</summary>
[Fact]
public void Approve_ConfirmDialog_ShowsSubmissionAge()
{
@@ -316,6 +325,7 @@ public class SecuredWritesTests : BunitContext
// ── (f) Approve/Reject confirm-button severity (register row 75) ──
/// <summary>Verifies that the approve confirmation dialog uses caution styling rather than destructive styling.</summary>
[Fact]
public void Approve_ConfirmDialog_IsCautionNotDestructive()
{
@@ -343,6 +353,7 @@ public class SecuredWritesTests : BunitContext
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>());
}
/// <summary>Verifies that the reject confirmation dialog keeps its destructive styling.</summary>
[Fact]
public void Reject_ConfirmDialog_KeepsDestructiveStyling()
{
@@ -364,6 +375,7 @@ public class SecuredWritesTests : BunitContext
Arg.Any<string>(), Arg.Any<string>(), danger: true, confirmLabel: "Reject");
}
/// <summary>Verifies that a Viewer does not see the Operator or Verifier regions.</summary>
[Fact]
public void Viewer_DoesNotSeeOperatorOrVerifierRegions()
{
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Shared;
/// <summary>
/// bUnit tests for <see cref="KpiTrendChart"/> (M6 K12) — the reusable
/// bUnit tests for <see cref="KpiTrendChart"/> — the reusable
/// dependency-free SVG trend chart. Coverage pins the three render states
/// (multi-point chart, unavailable placeholder, single-sample note), the stable
/// <c>data-test</c> slug, and the coordinate-math edge guards (all-equal
@@ -22,6 +22,7 @@ public class KpiTrendChartTests : BunitContext
new KpiSeriesPoint(Base.AddMinutes(10), 2.0),
};
/// <summary>Verifies that an available series with three points renders a polyline, a title and a data-test attribute.</summary>
[Fact]
public void Available_WithThreePoints_RendersPolylineTitleAndDataTest()
{
@@ -38,6 +39,7 @@ public class KpiTrendChartTests : BunitContext
Assert.Contains("<title>", markup);
}
/// <summary>Verifies that a unit is appended to the rendered value labels.</summary>
[Fact]
public void Available_WithUnit_AppendsUnitToValueLabels()
{
@@ -51,6 +53,7 @@ public class KpiTrendChartTests : BunitContext
Assert.Contains("max 4 s", cut.Markup);
}
/// <summary>Verifies that an unavailable series renders an em dash placeholder and the error text without a polyline.</summary>
[Fact]
public void Unavailable_RendersEmDashPlaceholderAndError_NoPolyline()
{
@@ -66,6 +69,7 @@ public class KpiTrendChartTests : BunitContext
Assert.DoesNotContain("<polyline", markup);
}
/// <summary>Verifies that null points render the placeholder without a polyline.</summary>
[Fact]
public void NullPoints_RendersPlaceholder_NoPolyline()
{
@@ -79,6 +83,7 @@ public class KpiTrendChartTests : BunitContext
Assert.DoesNotContain("<polyline", markup);
}
/// <summary>Verifies that a single point renders the single-sample state without a polyline and does not throw.</summary>
[Fact]
public void SinglePoint_RendersSingleSampleState_NoPolyline_DoesNotThrow()
{
@@ -92,6 +97,7 @@ public class KpiTrendChartTests : BunitContext
Assert.DoesNotContain("<polyline", markup);
}
/// <summary>Verifies that points with identical timestamps render a polyline without throwing.</summary>
[Fact]
public void AllEqualTimestamps_DoesNotThrow_AndRendersPolyline()
{
@@ -114,6 +120,7 @@ public class KpiTrendChartTests : BunitContext
Assert.DoesNotContain("Infinity", markup);
}
/// <summary>Verifies that all-zero values render a flat polyline without throwing.</summary>
[Fact]
public void AllZeroValues_DoesNotThrow_AndRendersFlatPolyline()
{
@@ -136,6 +143,9 @@ public class KpiTrendChartTests : BunitContext
Assert.DoesNotContain("Infinity", markup);
}
/// <summary>Verifies that a chart title is converted to a stable data-test slug.</summary>
/// <param name="title">The chart title to slugify.</param>
/// <param name="expectedDataTest">The expected data-test slug.</param>
[Theory]
[InlineData("Queue Depth", "kpi-trend-queue-depth")]
[InlineData("Oldest Pending (s)", "kpi-trend-oldest-pending-s")]
@@ -7,6 +7,9 @@ namespace Microsoft.Extensions.DependencyInjection;
internal static class ConfigurationMutationTestExtensions
{
/// <summary>Registers the configuration command services with test doubles for auditing and transactions.</summary>
/// <param name="services">The service collection to add the registrations to.</param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddTestConfigurationMutations(this IServiceCollection services)
{
services.TryAddScoped<ConfigurationCommandService>();
@@ -17,6 +20,7 @@ internal static class ConfigurationMutationTestExtensions
}
private sealed class RepositoryTransaction(IServiceScopeFactory scopes) : IConfigurationCommandTransaction
{
/// <inheritdoc />
public async Task<object?> ExecuteAsync(Func<IServiceProvider, Task<object?>> operation)
{
using var scope = scopes.CreateScope();
@@ -17,6 +17,7 @@ public class DataConnectionFormTests : BunitContext
{
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
/// <summary>Initializes the test context and registers the substitute services the form requires.</summary>
public DataConnectionFormTests()
{
Services.AddSingleton(_siteRepo);
@@ -51,6 +52,7 @@ public class DataConnectionFormTests : BunitContext
return Render<DataConnectionForm>();
}
/// <summary>Verifies that the protocol dropdown is rendered on create and offers both protocols.</summary>
[Fact]
public void ProtocolDropdown_IsRendered_OnCreate_WithBothProtocols()
{
@@ -64,6 +66,8 @@ public class DataConnectionFormTests : BunitContext
Assert.Contains("MxGateway", optionTexts);
}
/// <summary>Verifies that saving an MxGateway connection persists typed JSON with the MxGateway protocol.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Save_MxGateway_PersistsTypedJsonAndProtocolMxGateway()
{
@@ -103,6 +107,8 @@ public class DataConnectionFormTests : BunitContext
doc.RootElement.GetProperty("endpoint").GetString());
}
/// <summary>Verifies that saving an invalid primary URL does not call the repository.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Save_InvalidPrimaryUrl_DoesNotCallRepo()
{
@@ -126,6 +132,8 @@ public class DataConnectionFormTests : BunitContext
Assert.Contains("Endpoint URL must be a valid", cut.Markup);
}
/// <summary>Verifies that saving a valid configuration persists typed JSON with the OPC UA protocol.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Save_ValidConfig_PersistsTypedJsonAndProtocolOpcUa()
{
@@ -21,6 +21,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
/// </summary>
public class DataConnectionsPageTests : BunitContext
{
/// <summary>Verifies the case: storage Save Fixture, completes Invocation Immediately.</summary>
[Fact]
public void StorageSaveFixture_CompletesInvocationImmediately()
{
@@ -29,17 +30,18 @@ public class DataConnectionsPageTests : BunitContext
}
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
// M9-T25: the design page now injects IConnectionHealthQueryService to render
// The design page now injects IConnectionHealthQueryService to render
// per-connection live health badges. A substitute defaulting to an empty map
// keeps every existing test green (no badge / unknown state when no report).
private readonly IConnectionHealthQueryService _healthQuery =
Substitute.For<IConnectionHealthQueryService>();
// M9-T24b: the page now opens a Move-to-Site dialog that dispatches the move
// The page now opens a Move-to-Site dialog that dispatches the move
// through IDataConnectionMoveService (the guard-running ManagementActor path).
// Substituted so the page renders without a real ManagementActor in scope.
private readonly IDataConnectionMoveService _moveService =
Substitute.For<IDataConnectionMoveService>();
/// <summary>Initializes a new instance of the <see cref="DataConnectionsPageTests"/> class.</summary>
public DataConnectionsPageTests()
{
Services.AddSingleton(_siteRepo);
@@ -48,7 +50,7 @@ public class DataConnectionsPageTests : BunitContext
// Satisfy the page's [Inject] IDialogService — the host that actually
// renders the dialog lives in MainLayout, not in bUnit's render scope.
Services.AddScoped<IDialogService, DialogService>();
// arch-review P6-4: the page now filters its site/connection tree through
// The page now filters its site/connection tree through
// SiteScopeService, so it must be resolvable in every render test.
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
AddTestAuth();
@@ -90,6 +92,7 @@ public class DataConnectionsPageTests : BunitContext
.FirstOrDefault(row => row.QuerySelector(".tv-label")?.TextContent == label)
?.QuerySelector(".tv-toggle");
/// <summary>Verifies the case: renders, empty State, when No Sites.</summary>
[Fact]
public void Renders_EmptyState_WhenNoSites()
{
@@ -100,6 +103,7 @@ public class DataConnectionsPageTests : BunitContext
Assert.Contains("No sites configured", cut.Markup);
}
/// <summary>Verifies the case: renders, empty Site, as Top Level Node.</summary>
[Fact]
public void Renders_EmptySite_AsTopLevelNode()
{
@@ -112,6 +116,7 @@ public class DataConnectionsPageTests : BunitContext
Assert.Contains("Plant-A", cut.Markup);
}
/// <summary>Verifies the case: renders, site Connection, nesting.</summary>
[Fact]
public void Renders_SiteConnection_Nesting()
{
@@ -129,6 +134,7 @@ public class DataConnectionsPageTests : BunitContext
Assert.Contains("OpcUa", cut.Markup);
}
/// <summary>Verifies the case: search, dims Non Matches, preserves Shape.</summary>
[Fact]
public void Search_DimsNonMatches_PreservesShape()
{
@@ -156,6 +162,7 @@ public class DataConnectionsPageTests : BunitContext
Assert.Contains(dimmedNodes, n => n.TextContent.Contains("Plant-B"));
}
/// <summary>Verifies the case: add Connection Button, disabled Until Site Selected.</summary>
[Fact]
public void AddConnectionButton_DisabledUntilSiteSelected()
{
@@ -178,6 +185,7 @@ public class DataConnectionsPageTests : BunitContext
Assert.False(addBtnAfter.HasAttribute("disabled"));
}
/// <summary>Verifies the case: data Connections Routes, are Declared On List Page.</summary>
[Fact]
public void DataConnectionsRoutes_AreDeclaredOnListPage()
{
@@ -194,10 +202,11 @@ public class DataConnectionsPageTests : BunitContext
Assert.Contains("/design/data-connections", routes);
}
/// <summary>Verifies the case: connection Node, renders Live Health Badge, matching Status.</summary>
[Fact]
public void ConnectionNode_RendersLiveHealthBadge_MatchingStatus()
{
// M9-T25: a connection whose live health is Connected gets the success
// A connection whose live health is Connected gets the success
// health badge next to its node; a Disconnected one gets the danger badge.
SeedRepos(
sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
@@ -228,10 +237,11 @@ public class DataConnectionsPageTests : BunitContext
Assert.Contains("Disconnected", disconnectedBadge.TextContent);
}
/// <summary>Verifies the case: connection Node, has, move To Site Action.</summary>
[Fact]
public void ConnectionNode_Has_MoveToSiteAction()
{
// M9-T24b: each connection node exposes a "Move to Site…" action alongside
// Each connection node exposes a "Move to Site…" action alongside
// Edit/Delete.
SeedRepos(
sites: new[]
@@ -248,10 +258,11 @@ public class DataConnectionsPageTests : BunitContext
b => b.TextContent.Contains("Move to Site"));
}
/// <summary>Verifies the case: move To Site, opens Dialog, excluding Current Site.</summary>
[Fact]
public void MoveToSite_OpensDialog_ExcludingCurrentSite()
{
// M9-T24b: clicking "Move to Site…" opens the move dialog whose target-site
// Clicking "Move to Site…" opens the move dialog whose target-site
// picker omits the connection's current site (Plant-A) and lists the others.
SeedRepos(
sites: new[]
@@ -284,10 +295,11 @@ public class DataConnectionsPageTests : BunitContext
Assert.DoesNotContain(optionLabels, l => l.Contains("Plant-A"));
}
// ── arch-review P6-4: site scoping. This page reads ISiteRepository directly,
// ── site scoping. This page reads ISiteRepository directly,
// bypassing ManagementActor's scope gate, and Designer is NOT exempt from
// site scope — so a scoped Designer used to see every site's connections. ──
/// <summary>Verifies the case: site Scoping, scoped Designer, sees Only Permitted Sites And Connections.</summary>
[Fact]
public void SiteScoping_ScopedDesigner_SeesOnlyPermittedSitesAndConnections()
{
@@ -321,6 +333,7 @@ public class DataConnectionsPageTests : BunitContext
Assert.DoesNotContain("PLC-B", cut.Markup);
}
/// <summary>Verifies the case: site Scoping, system Wide User, sees Every Connection.</summary>
[Fact]
public void SiteScoping_SystemWideUser_SeesEveryConnection()
{
@@ -14,6 +14,7 @@ public class DebugTreeBuilderTests
private static AttributeValueChanged Attr(string name, object? value = null, string quality = "Good")
=> new("Inst", name, name, value, quality, DateTimeOffset.UtcNow);
/// <summary>Verifies that a root-level attribute becomes a single leaf.</summary>
[Fact]
public void RootLevelAttribute_BecomesSingleLeaf()
{
@@ -27,6 +28,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Speed", node.Attribute!.AttributeName);
}
/// <summary>Verifies two sibling leaves share single branch sorted by segment.</summary>
[Fact]
public void TwoSiblingLeaves_ShareSingleBranch_SortedBySegment()
{
@@ -47,6 +49,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Temp", branch.Children[1].Segment);
}
/// <summary>Verifies that a deep path nests branches by accumulated prefix.</summary>
[Fact]
public void DeepPath_NestsBranchesByAccumulatedPrefix()
{
@@ -70,6 +73,7 @@ public class DebugTreeBuilderTests
Assert.NotNull(pump.Attribute);
}
/// <summary>Verifies that branches are deduplicated across deep paths sharing a prefix.</summary>
[Fact]
public void BranchDedupe_SharedPrefixAcrossDeepPaths()
{
@@ -95,6 +99,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Motor1.Compressor.Valve", compressor.Children[1].Key);
}
/// <summary>Verifies that a bad-quality descendant marks all ancestor branches as bad.</summary>
[Fact]
public void RollUp_BadQualityDescendant_MarksAllAncestorBranches()
{
@@ -113,6 +118,7 @@ public class DebugTreeBuilderTests
Assert.True(compressor.HasBadQuality);
}
/// <summary>Verifies that a branch whose leaves are all good is not marked as bad quality.</summary>
[Fact]
public void RollUp_AllGood_LeavesBranchHasBadQualityFalse()
{
@@ -123,6 +129,8 @@ public class DebugTreeBuilderTests
Assert.False(motor.HasBadQuality);
}
/// <summary>Verifies that any non-good quality counts as bad when rolling up.</summary>
/// <param name="quality">The test input.</param>
[Theory]
[InlineData("Uncertain")]
[InlineData("Bad")]
@@ -134,6 +142,7 @@ public class DebugTreeBuilderTests
Assert.True(Assert.Single(tree).HasBadQuality);
}
/// <summary>Verifies that filtering keeps only the matching leaves and their ancestors.</summary>
[Fact]
public void Filter_KeepsOnlyMatchingLeaves_AndTheirAncestors()
{
@@ -153,6 +162,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Motor1.Temp", leaf.Key);
}
/// <summary>Verifies that filtering is case insensitive.</summary>
[Fact]
public void Filter_IsCaseInsensitive()
{
@@ -163,6 +173,8 @@ public class DebugTreeBuilderTests
Assert.Equal("Motor1.Temp", Assert.Single(motor.Children).Key);
}
/// <summary>Verifies that an empty or whitespace filter returns the full forest.</summary>
/// <param name="filter">The test input.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -177,6 +189,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Motor2", tree[1].Key);
}
/// <summary>Verifies that a filter with no matches returns an empty forest.</summary>
[Fact]
public void Filter_NoMatches_ReturnsEmptyForest()
{
@@ -186,6 +199,7 @@ public class DebugTreeBuilderTests
Assert.Empty(tree);
}
/// <summary>Verifies that the top-level forest is sorted by segment.</summary>
[Fact]
public void TopLevelForest_SortedBySegment()
{
@@ -198,6 +212,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Zeta", tree[2].Key);
}
/// <summary>Verifies that empty input returns an empty forest.</summary>
[Fact]
public void EmptyInput_ReturnsEmptyForest()
{
@@ -205,6 +220,7 @@ public class DebugTreeBuilderTests
Assert.Empty(tree);
}
/// <summary>Verifies that bad quality four levels deep rolls up to the root.</summary>
[Fact]
public void RollUp_FourLevelDeepBadQuality_ReachesRoot()
{
@@ -221,6 +237,7 @@ public class DebugTreeBuilderTests
Assert.True(d.HasBadQuality);
}
/// <summary>Verifies that a filter matching a deep leaf retains all its ancestor branches.</summary>
[Fact]
public void Filter_DeepLeafMatch_RetainsAllAncestorBranches()
{
@@ -235,7 +252,7 @@ public class DebugTreeBuilderTests
}
// ---------------------------------------------------------------------
// DV-4: BuildAlarmTree — computed leaves, native condition groupings,
// BuildAlarmTree — computed leaves, native condition groupings,
// configured-placeholder binding nodes, roll-up, filter.
// ---------------------------------------------------------------------
@@ -261,6 +278,7 @@ public class DebugTreeBuilderTests
IsConfiguredPlaceholder = placeholder,
};
/// <summary>Verifies that a computed alarm with a path-qualified name becomes a leaf under a branch.</summary>
[Fact]
public void Computed_PathQualifiedName_BecomesLeafUnderBranch()
{
@@ -282,6 +300,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Motor1.HighTemp", leaf.Alarm!.AlarmName);
}
/// <summary>Verifies that a computed alarm with a root-level name becomes a single leaf.</summary>
[Fact]
public void Computed_RootLevelName_BecomesSingleLeaf()
{
@@ -294,6 +313,7 @@ public class DebugTreeBuilderTests
Assert.NotNull(node.Alarm);
}
/// <summary>Verifies that a native condition nests under its binding node with the source reference segment.</summary>
[Fact]
public void Native_Condition_NestsUnderBindingNode_WithSourceReferenceSegment()
{
@@ -318,6 +338,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Tank1.Level.HiHi", condition.Alarm!.SourceReference);
}
/// <summary>Verifies that two native conditions share the same binding node.</summary>
[Fact]
public void Native_TwoConditions_ShareSameBindingNode()
{
@@ -340,6 +361,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Tank1.Levels::Tank1.Level.LoLo", binding.Children[1].Key);
}
/// <summary>Verifies that a native placeholder yields a binding node without children.</summary>
[Fact]
public void Native_Placeholder_YieldsChildlessBindingNode()
{
@@ -354,10 +376,11 @@ public class DebugTreeBuilderTests
Assert.Empty(binding.Children);
}
/// <summary>Verifies that real conditions win over a placeholder under the same native binding.</summary>
[Fact]
public void Native_PlaceholderAndRealConditions_RealConditionsWin()
{
// DV-2 shouldn't emit both, but be safe: placeholder adds nothing.
// Shouldn't emit both, but be safe: placeholder adds nothing.
var tree = DebugTreeBuilder.BuildAlarmTree(
new[]
{
@@ -373,6 +396,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Tank1.Levels::Tank1.Level.HiHi", condition.Key);
}
/// <summary>Verifies that an active descendant sets the worst state to active and is counted when rolling up.</summary>
[Fact]
public void RollUp_ActiveDescendant_SetsWorstStateActiveAndCounts()
{
@@ -394,6 +418,7 @@ public class DebugTreeBuilderTests
Assert.Equal(1, binding.ActiveCount);
}
/// <summary>Verifies that when every alarm is normal the worst state is normal with a zero active count.</summary>
[Fact]
public void RollUp_AllNormal_WorstStateNormalAndZeroCount()
{
@@ -405,6 +430,7 @@ public class DebugTreeBuilderTests
Assert.Equal(0, tank.ActiveCount);
}
/// <summary>Verifies that a placeholder never counts as active when rolling up.</summary>
[Fact]
public void RollUp_Placeholder_NeverCountsAsActive()
{
@@ -423,6 +449,7 @@ public class DebugTreeBuilderTests
Assert.Equal(0, binding.ActiveCount);
}
/// <summary>Verifies that filtering on the source reference keeps the condition under its binding and ancestors.</summary>
[Fact]
public void Filter_OnSourceReference_KeepsConditionUnderBindingAndAncestors()
{
@@ -443,6 +470,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Tank1.Levels::Tank1.Level.HiHi", condition.Key);
}
/// <summary>Verifies that filtering on a binding canonical name keeps the binding node.</summary>
[Fact]
public void Filter_OnBindingCanonicalName_KeepsBindingNode()
{
@@ -463,6 +491,7 @@ public class DebugTreeBuilderTests
Assert.Single(binding.Children);
}
/// <summary>Verifies that filtering on a binding name that matches a placeholder keeps the binding node.</summary>
[Fact]
public void Filter_OnBindingName_MatchingPlaceholder_KeepsBindingNode()
{
@@ -477,6 +506,7 @@ public class DebugTreeBuilderTests
Assert.False(binding.HasChildren);
}
/// <summary>Verifies that filtering on a computed alarm name is case insensitive.</summary>
[Fact]
public void Filter_OnComputedAlarmName_IsCaseInsensitive()
{
@@ -488,6 +518,8 @@ public class DebugTreeBuilderTests
Assert.Equal("Motor1.HighTemp", Assert.Single(motor.Children).Key);
}
/// <summary>Verifies that an empty or whitespace filter returns the full alarm forest.</summary>
/// <param name="filter">The test input.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -503,6 +535,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Tank1", tree[1].Key);
}
/// <summary>Verifies that a filter with no matches returns an empty alarm forest.</summary>
[Fact]
public void Filter_NoMatches_ReturnsEmptyAlarmForest()
{
@@ -512,6 +545,7 @@ public class DebugTreeBuilderTests
Assert.Empty(tree);
}
/// <summary>Verifies that mixed computed and native alarms produce both subtrees.</summary>
[Fact]
public void MixedComputedAndNative_ProduceBothSubtrees()
{
@@ -536,6 +570,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Tank1.Levels::Tank1.Level.HiHi", Assert.Single(binding.Children).Key);
}
/// <summary>Verifies that empty alarm input returns an empty forest.</summary>
[Fact]
public void EmptyAlarmInput_ReturnsEmptyForest()
{
@@ -543,6 +578,7 @@ public class DebugTreeBuilderTests
Assert.Empty(tree);
}
/// <summary>Verifies that computed and native alarms sharing a prefix keep distinct binding and leaf nodes.</summary>
[Fact]
public void Native_ComputedAndNativeSharePrefix_DistinctBindingAndLeaf()
{
@@ -20,7 +20,7 @@ using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploymen
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// DV-5: the Debug View renders a tabbed (Attributes / Alarms) layout, each tab
/// The Debug View renders a tabbed (Attributes / Alarms) layout, each tab
/// hosting a composition <c>TreeView</c>. Computed alarms and attributes nest as
/// leaves under their composition-member branches; native conditions nest under
/// their source-binding node. Branch nodes surface a roll-up (active-count /
@@ -62,7 +62,10 @@ public class DebugViewAlarmTableTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Initializes the provider with the fixed authentication state to return.</summary>
/// <param name="state">The authentication state returned to every caller.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(_state);
}
@@ -98,6 +101,7 @@ public class DebugViewAlarmTableTests : BunitContext
cut.Render();
}
/// <summary>Verifies that both tabs are present, the Attributes tab is the default, and it shows the attribute tree.</summary>
[Fact]
public void BothTabsPresent_AttributesTabIsDefault_AndShowsAttributeTree()
{
@@ -132,6 +136,7 @@ public class DebugViewAlarmTableTests : BunitContext
Assert.NotNull(motor1.QuerySelector("ul[role='group'] li[role='treeitem']"));
}
/// <summary>Verifies that the Alarms tab groups a computed alarm under its branch with an active roll-up badge.</summary>
[Fact]
public void AlarmsTab_GroupsComputedAlarmUnderBranch_WithActiveRollUpBadge()
{
@@ -168,6 +173,7 @@ public class DebugViewAlarmTableTests : BunitContext
Assert.NotNull(highTemp.QuerySelector(".badge.bg-danger")); // Active state badge
}
/// <summary>Verifies that the Alarms tab renders a native condition with enriched badges under its binding node.</summary>
[Fact]
public void AlarmsTab_RendersNativeCondition_WithEnrichedBadgesUnderBindingNode()
{
@@ -18,7 +18,7 @@ using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploymen
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Regression tests for arch-review 2026-08-19 P6-6. <c>Connect()</c> awaits
/// Regression tests for a dispose race in the debug view. <c>Connect()</c> awaits
/// <c>StartStreamAsync</c> — which can take up to 30s — and then publishes the result into
/// <c>_session</c>. If the circuit was torn down inside that window, <c>Dispose()</c> had
/// already run, found <c>_session</c> still null and stopped nothing, so the just-created
@@ -40,12 +40,17 @@ public class DebugViewConnectDisposeRaceTests : BunitContext
private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly List<string> _stopped = new();
/// <summary>Creates a debug stream service whose stream start is held until released.</summary>
/// <param name="comms">The communication service.</param>
/// <param name="provider">The service provider.</param>
/// <param name="factory">The gRPC client factory.</param>
public GatedDebugStreamService(
CommunicationService comms, IServiceProvider provider, SiteStreamGrpcClientFactory factory)
: base(comms, provider, factory, NullLogger<DebugStreamService>.Instance)
{
}
/// <summary>The id of the session this service hands out.</summary>
public string SessionId => "session-under-test";
/// <summary>Completes once <see cref="StartStreamAsync"/> has been entered.</summary>
@@ -57,8 +62,10 @@ public class DebugViewConnectDisposeRaceTests : BunitContext
get { lock (_stopped) { return _stopped.ToList(); } }
}
/// <summary>Releases the held stream start so it can complete.</summary>
public void ReleaseStart() => _gate.TrySetResult();
/// <inheritdoc />
public override async Task<DebugStreamSession> StartStreamAsync(
int instanceId, Action<object> onEvent, Action onTerminated, CancellationToken ct = default)
{
@@ -73,6 +80,7 @@ public class DebugViewConnectDisposeRaceTests : BunitContext
DateTimeOffset.UtcNow));
}
/// <inheritdoc />
public override void StopStream(string sessionId)
{
lock (_stopped) { _stopped.Add(sessionId); }
@@ -82,7 +90,10 @@ public class DebugViewConnectDisposeRaceTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Creates a provider that always returns the given authentication state.</summary>
/// <param name="state">The authentication state to return.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
@@ -141,6 +152,8 @@ public class DebugViewConnectDisposeRaceTests : BunitContext
return task!;
}
/// <summary>Verifies that disposing during a pending connect stops the just-created session.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Connect_DisposedMidStart_StopsTheJustCreatedSession()
{
@@ -162,6 +175,8 @@ public class DebugViewConnectDisposeRaceTests : BunitContext
Assert.Null(GetField(cut.Instance, "_session"));
}
/// <summary>Verifies that connecting and then disposing stops the session exactly once.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Connect_ThenDispose_StopsTheSessionExactlyOnce()
{
@@ -186,6 +201,8 @@ public class DebugViewConnectDisposeRaceTests : BunitContext
Assert.Equal(new[] { stream.SessionId }, stream.Stopped);
}
/// <summary>Verifies that disconnecting after a connect stops the session once and clears the session slot.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Disconnect_AfterConnect_StopsTheSessionOnceAndClearsTheSlot()
{
@@ -16,7 +16,7 @@ using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploymen
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Regression tests for CentralUI-009. The <c>DebugView</c> stream callbacks
/// Regression tests for the debug view disposal guard. The <c>DebugView</c> stream callbacks
/// (<c>onEvent</c>/<c>onTerminated</c>) run on an Akka/gRPC thread and capture
/// <c>this</c> and <c>_toast</c>. If the user navigates away, an in-flight
/// callback could still call <c>_toast.ShowError(...)</c> /
@@ -69,11 +69,15 @@ public class DebugViewDisposalTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Creates a provider that always returns the given authentication state.</summary>
/// <param name="state">The authentication state to return.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
/// <summary>Verifies that the debug view exposes a boolean disposal guard field.</summary>
[Fact]
public void DebugView_HasDisposalGuardField()
{
@@ -86,6 +90,7 @@ public class DebugViewDisposalTests : BunitContext
Assert.Equal(typeof(bool), field!.FieldType);
}
/// <summary>Verifies that disposing the debug view sets the disposal guard and that disposing twice does not throw.</summary>
[Fact]
public void DebugView_Dispose_SetsDisposedFlag_AndIsIdempotent()
{
@@ -81,7 +81,10 @@ public class DebugViewInstancePickerTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Initializes the provider with a fixed authentication state.</summary>
/// <param name="state">The state.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
@@ -101,6 +104,7 @@ public class DebugViewInstancePickerTests : BunitContext
.First(r => r.TextContent.Trim() == text)
.Click();
/// <summary>Verifies that the instance selector is the picker component rather than a flat select.</summary>
[Fact]
public void InstanceSelector_IsThePicker_NotAFlatSelect()
{
@@ -111,6 +115,7 @@ public class DebugViewInstancePickerTests : BunitContext
Assert.Empty(cut.FindAll("select[data-test='debug-instance-select']"));
}
/// <summary>Verifies that opening the picker shows only the root area.</summary>
[Fact]
public void OpeningThePicker_ShowsOnlyTheRootArea()
{
@@ -122,6 +127,7 @@ public class DebugViewInstancePickerTests : BunitContext
Assert.False(rows[0].HasAttribute("data-instance-id"));
}
/// <summary>Verifies that drilling down reveals only the enabled instance.</summary>
[Fact]
public void DrillingDown_RevealsTheEnabledInstanceOnly()
{
@@ -139,6 +145,7 @@ public class DebugViewInstancePickerTests : BunitContext
r => r.TextContent.Contains("Off01"));
}
/// <summary>Verifies that picking an instance fills the field and enables the Connect button.</summary>
[Fact]
public void PickingAnInstance_FillsTheField_AndEnablesConnect()
{
@@ -211,6 +218,7 @@ public class DebugViewInstancePickerTests : BunitContext
JSInterop.VerifyInvoke("localStorage.removeItem");
}
/// <summary>Verifies that the picker is disabled while the view is connected.</summary>
[Fact]
public void WhileConnected_ThePickerIsDisabled()
{
@@ -18,7 +18,7 @@ using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploymen
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Regression tests for the Debug View render coalescing (arch-review WP2.4). Every streamed
/// Regression tests for the Debug View render coalescing. Every streamed
/// event used to marshal onto the circuit dispatcher and call <c>StateHasChanged</c> on its
/// own, so a chatty instance drove one full render — and two full composition-tree rebuilds —
/// per value change. Events are now buffered and applied once per ~250 ms window: a burst
@@ -60,7 +60,10 @@ public class DebugViewRenderCoalescingTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Initializes the provider with the authentication state to return.</summary>
/// <param name="state">The authentication state to return.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
@@ -78,6 +81,7 @@ public class DebugViewRenderCoalescingTests : BunitContext
new AttributeValueChanged("Inst-1", name, name, value, "Good", DateTimeOffset.UtcNow),
});
/// <summary>Verifies that a burst of events triggers a single render while every event is still applied.</summary>
[Fact]
public void BurstOfEvents_RendersOnce_ButAppliesEveryEvent()
{
@@ -99,6 +103,7 @@ public class DebugViewRenderCoalescingTests : BunitContext
Assert.InRange(renders, 1, 5);
}
/// <summary>Verifies that repeated updates to one tag collapse to the latest value.</summary>
[Fact]
public void RepeatedUpdatesToOneTag_CollapseToTheLatestValue()
{
@@ -115,6 +120,7 @@ public class DebugViewRenderCoalescingTests : BunitContext
Assert.Equal(49, applied.Value);
}
/// <summary>Verifies that an event arriving after a flush still arms a new coalescing window.</summary>
[Fact]
public void EventArrivingAfterAFlush_StillArmsANewWindow()
{
@@ -130,6 +136,7 @@ public class DebugViewRenderCoalescingTests : BunitContext
cut.WaitForState(() => dict.Count == 2, TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that events arriving after disposal are dropped without throwing.</summary>
[Fact]
public void EventsAfterDispose_AreDroppedWithoutThrowing()
{
@@ -18,7 +18,7 @@ using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploymen
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Regression tests for CentralUI-021. The <c>DebugView</c> stream callback runs
/// Regression tests for the DebugView stream race. The <c>DebugView</c> stream callback runs
/// on an Akka/gRPC thread; it used to call <c>UpsertWithCap</c> directly on that
/// thread, mutating the <c>_attributeValues</c>/<c>_alarmStates</c>
/// <see cref="Dictionary{TKey,TValue}"/> while the render thread enumerated the
@@ -67,7 +67,10 @@ public class DebugViewStreamRaceTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Initializes a new instance of the <c>StubAuthStateProvider</c> class.</summary>
/// <param name="state">The authentication state the provider returns.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
@@ -83,6 +86,7 @@ public class DebugViewStreamRaceTests : BunitContext
typeof(DebugViewPage).GetProperty("FilteredAttributeValues",
BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(c)!;
/// <summary>Verifies that a stream event is applied once the dispatcher runs its queued work.</summary>
[Fact]
public void HandleStreamEvent_AppliesUpdate_OnceDispatcherRuns()
{
@@ -98,10 +102,12 @@ public class DebugViewStreamRaceTests : BunitContext
Assert.True(dict.Contains("Pump.Speed"));
}
/// <summary>Verifies that stream events raised from background threads do not fault concurrent dispatcher reads.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task HandleStreamEvent_OffThreadEvents_DoNotFaultDispatcherReads()
{
// CentralUI-021 reproduction. Writers fire stream events from background
// Stream race reproduction. Writers fire stream events from background
// threads (the Akka/gRPC callback threads). The reader enumerates
// FilteredAttributeValues *through the renderer's dispatcher* — exactly
// as the real render thread does. Pre-fix the writers mutated the
@@ -15,7 +15,7 @@ using DeploymentsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploym
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Regression tests for CentralUI-006. Component-CentralUI "Real-Time Updates"
/// Regression tests for deployment status push updates. Component-CentralUI "Real-Time Updates"
/// states deployment status transitions push to the UI immediately via SignalR
/// with no polling. The page previously ran a 10-second <c>Timer</c> that
/// reloaded every deployment record + instance map per tick. The fix removes
@@ -61,11 +61,15 @@ public class DeploymentsPushUpdateTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Initializes the provider with the authentication state it returns.</summary>
/// <param name="state">The authentication state to return.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
/// <summary>Verifies that the Deployments page does not poll and has no refresh timer.</summary>
[Fact]
public void Deployments_DoesNotPoll_HasNoRefreshTimer()
{
@@ -76,6 +80,7 @@ public class DeploymentsPushUpdateTests : BunitContext
Assert.Null(timerField);
}
/// <summary>Verifies that a deployment status change notification triggers a reload of the page.</summary>
[Fact]
public void Deployments_StatusChange_TriggersReload()
{
@@ -95,6 +100,7 @@ public class DeploymentsPushUpdateTests : BunitContext
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>()));
}
/// <summary>Verifies that disposing the page unsubscribes it from the status notifier.</summary>
[Fact]
public void Deployments_Dispose_UnsubscribesFromNotifier()
{
@@ -225,7 +231,7 @@ public class DeploymentsPushUpdateTests : BunitContext
}
/// <summary>
/// Regression test for CentralUI-022. The notifier is a process singleton:
/// Regression test for the dispose race. The notifier is a process singleton:
/// it can read its subscriber list and begin invoking
/// <c>OnDeploymentStatusChanged</c> on the DeploymentManager thread an
/// instant before the component is disposed. The handler must no-op against
@@ -242,6 +248,7 @@ public class DeploymentsPushUpdateTests : BunitContext
Assert.Equal(typeof(bool), field!.FieldType);
}
/// <summary>Verifies that a status change delivered after disposal neither throws nor reloads the page.</summary>
[Fact]
public void Deployments_StatusChangeAfterDispose_DoesNotThrowOrReload()
{
@@ -14,7 +14,7 @@ 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). The
/// Regression tests for the Deployment Status push coalescing. 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.
@@ -57,7 +57,10 @@ public class DeploymentsReloadDebounceTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Initializes a new instance of the <see cref="StubAuthStateProvider"/> class with a fixed authentication state.</summary>
/// <param name="state">The authentication state to return.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
@@ -67,6 +70,7 @@ public class DeploymentsReloadDebounceTests : BunitContext
_deployRepo.Received().QueryDeploymentListPageAsync(
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>()));
/// <summary>Verifies that a burst of status writes collapses into far fewer page reloads.</summary>
[Fact]
public void BurstOfStatusWrites_CollapsesIntoFarFewerReloads()
{
@@ -93,6 +97,7 @@ public class DeploymentsReloadDebounceTests : BunitContext
Assert.InRange(reloads, 1, 4);
}
/// <summary>Verifies that the first push after an idle period reloads the page immediately.</summary>
[Fact]
public void FirstPushAfterIdle_ReloadsImmediately()
{
@@ -112,6 +117,7 @@ public class DeploymentsReloadDebounceTests : BunitContext
TimeSpan.FromMilliseconds(400));
}
/// <summary>Verifies that disposing the page during a coalesce window does not trigger a reload.</summary>
[Fact]
public void DisposeDuringACoalesceWindow_DoesNotReload()
{
@@ -15,7 +15,7 @@ using DeploymentsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploym
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Regression tests for arch-review 2026-08-19 P6-11. Three independent initiators —
/// Regression tests for stale request generations on the Deployment Status page. Three independent initiators —
/// search, paging, and the push/coalesce reload — can each have a
/// <c>LoadDataAsync</c> flight in flight on the Deployment Status page at once. Every
/// flight awaits its own round-trip to central MS SQL, so COMPLETION order is not
@@ -92,7 +92,10 @@ public class DeploymentsRequestGenerationTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Initializes the provider with the authentication state it will return.</summary>
/// <param name="state">The authentication state to return.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
@@ -146,18 +149,20 @@ public class DeploymentsRequestGenerationTests : BunitContext
.GetField("_currentPage", BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(page)!;
/// <summary>Verifies that a stale page 2 flight arriving after a newer flight does not overwrite the grid.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StaleFlightArrivingAfterANewerOne_DoesNotOverwriteTheGrid()
{
RegisterServices();
var initialRows = Enumerable.Range(1, 25).Select(i => Row(i, $"Init-{i}")).ToList();
_pageResponses.Add(CompletedGate(new DeploymentListPage(initialRows, TotalCount: 100))); // call #1: initial render
_pageResponses.Add(CompletedGate(new DeploymentListPage(initialRows, TotalCount: 100))); // first call: initial render
var stale = Gate(); // call #2: page-2 flight — held open, the SLOW, STALE flight
var stale = Gate(); // second call: page 2 flight — held open, the SLOW, STALE flight
_pageResponses.Add(stale);
// call #3: status-filter flight — resolves immediately, the FAST, NEWER flight
// third call: status-filter flight — resolves immediately, the FAST, NEWER flight
_pageResponses.Add(CompletedGate(
new DeploymentListPage(new[] { Row(999, "TileFilteredFreshRow") }, TotalCount: 1)));
@@ -182,7 +187,7 @@ public class DeploymentsRequestGenerationTests : BunitContext
stale.SetResult(new DeploymentListPage(new[] { Row(2, "PageTwoStaleRow") }, TotalCount: 100));
await flightA.WaitAsync(TimeSpan.FromSeconds(5));
// Pre-fix, flight A's late arrival clobbers _rows with the stale page-2 data.
// Pre-fix, flight A's late arrival clobbers _rows with the stale page 2 data.
// Post-fix, its generation is no longer current, so it discards its own
// results and the newer grid survives untouched.
Assert.DoesNotContain(GetRows(cut.Instance), r => r.InstanceUniqueName == "PageTwoStaleRow");
@@ -190,7 +195,7 @@ public class DeploymentsRequestGenerationTests : BunitContext
}
/// <summary>
/// The "does the timer refresh reset the page mid-search" half of P6-11. The
/// The "does the timer refresh reset the page mid-search" half of that regression. The
/// filter-debounce ("timer") reload fires first, as if 500ms had elapsed after a
/// keystroke, and is held open. While it is still in flight the operator
/// navigates to page 2 by other means (a fresh, faster flight). The debounce
@@ -204,18 +209,19 @@ public class DeploymentsRequestGenerationTests : BunitContext
/// deterministically instead of racing bUnit's render pump.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StaleFilterDebounceFlight_DoesNotResetThePageAfterANewerNavigation()
{
RegisterServices();
var initialRows = Enumerable.Range(1, 25).Select(i => Row(i, $"Init-{i}")).ToList();
_pageResponses.Add(CompletedGate(new DeploymentListPage(initialRows, TotalCount: 100))); // call #1: initial render
_pageResponses.Add(CompletedGate(new DeploymentListPage(initialRows, TotalCount: 100))); // first call: initial render
var staleSearch = Gate(); // call #2: the debounce-triggered ("timer") flight — held open, STALE
var staleSearch = Gate(); // second call: the debounce-triggered ("timer") flight — held open, STALE
_pageResponses.Add(staleSearch);
// call #3: page-2 click — resolves immediately, NEWER.
// third call: page 2 click — resolves immediately, NEWER.
_pageResponses.Add(CompletedGate(
new DeploymentListPage(new[] { Row(2, "PageTwoFreshRow") }, TotalCount: 100)));
@@ -243,7 +249,7 @@ public class DeploymentsRequestGenerationTests : BunitContext
await staleFlight.WaitAsync(TimeSpan.FromSeconds(5));
// Pre-fix, the stale flight unconditionally wrote _currentPage = 1 up front
// and then overwrote the grid with page-1 data once it resolved — resetting
// and then overwrote the grid with page 1 data once it resolved — resetting
// the operator's page mid an already-completed navigation. Post-fix, its
// generation is no longer current, so both the page and the grid are left
// untouched.
@@ -79,7 +79,10 @@ public class DeploymentsServerPagingTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Initializes the provider with the authentication state it will return.</summary>
/// <param name="state">The authentication state returned to callers.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
@@ -100,6 +103,7 @@ public class DeploymentsServerPagingTests : BunitContext
// ── Paging ────────────────────────────────────────────────────────────────
/// <summary>Verifies that the initial load requests page one from the server and never reads every instance.</summary>
[Fact]
public void InitialLoad_RequestsPageOneFromTheServer_AndNeverReadsEveryInstance()
{
@@ -115,6 +119,7 @@ public class DeploymentsServerPagingTests : BunitContext
_templateRepo.DidNotReceiveWithAnyArgs().GetAllInstancesAsync();
}
/// <summary>Verifies that the server-reported total count drives the pager rather than the rows on screen.</summary>
[Fact]
public void TotalCountFromServer_DrivesThePager_NotTheRowsOnScreen()
{
@@ -133,6 +138,7 @@ public class DeploymentsServerPagingTests : BunitContext
Assert.Contains("6", cut.Find("ul.pagination").TextContent);
}
/// <summary>Verifies that moving to the next page re-queries the server for page two.</summary>
[Fact]
public void NextPage_RequeriesTheServerForPageTwo()
{
@@ -148,6 +154,7 @@ public class DeploymentsServerPagingTests : BunitContext
Assert.Equal(2, call.Page);
}
/// <summary>Verifies that a single-page result renders no pager.</summary>
[Fact]
public void SinglePageResult_RendersNoPager()
{
@@ -160,6 +167,7 @@ public class DeploymentsServerPagingTests : BunitContext
// ── Status tiles ──────────────────────────────────────────────────────────
/// <summary>Verifies that the status tiles show server-computed counts rather than counts of the visible page.</summary>
[Fact]
public void Tiles_RenderServerComputedCounts_NotCountsOfTheVisiblePage()
{
@@ -179,6 +187,7 @@ public class DeploymentsServerPagingTests : BunitContext
Assert.Contains("400", cut.Find("[data-test=\"deploy-tile-all\"]").TextContent);
}
/// <summary>Verifies that clicking a status tile applies the filter on the server and resets to page one.</summary>
[Fact]
public void ClickingAStatusTile_AppliesTheFilterServerSide_AndResetsToPageOne()
{
@@ -197,6 +206,7 @@ public class DeploymentsServerPagingTests : BunitContext
Assert.Equal("true", cut.Find("[data-test=\"deploy-tile-failed\"]").GetAttribute("aria-pressed"));
}
/// <summary>Verifies that clicking the active status tile again clears the status filter.</summary>
[Fact]
public void ClickingTheActiveTileAgain_ClearsTheStatusFilter()
{
@@ -214,6 +224,7 @@ public class DeploymentsServerPagingTests : BunitContext
// ── Site scoping ──────────────────────────────────────────────────────────
/// <summary>Verifies that a system-wide user queries with no site scope.</summary>
[Fact]
public void SystemWideUser_QueriesWithNoSiteScope()
{
@@ -224,6 +235,7 @@ public class DeploymentsServerPagingTests : BunitContext
Assert.Null(Assert.Single(_pageCalls).Filter.SiteIdScope);
}
/// <summary>Verifies that a site-scoped user's permitted site ids are pushed into the query.</summary>
[Fact]
public void SiteScopedUser_PushesThePermittedSiteIdsIntoTheQuery()
{
@@ -238,6 +250,7 @@ public class DeploymentsServerPagingTests : BunitContext
// ── Filter debounce ───────────────────────────────────────────────────────
/// <summary>Verifies that search input is debounced so a burst of keystrokes issues a single query.</summary>
[Fact]
public void SearchInput_IsDebounced_SoABurstOfKeystrokesIssuesOneQuery()
{
@@ -263,6 +276,7 @@ public class DeploymentsServerPagingTests : BunitContext
Assert.Equal(1, call.Page);
}
/// <summary>Verifies that clearing the filters resets the search text and status and re-queries the server.</summary>
[Fact]
public void ClearFilters_ResetsSearchAndStatus_AndRequeries()
{
@@ -280,6 +294,7 @@ public class DeploymentsServerPagingTests : BunitContext
Assert.Equal(1, call.Page);
}
/// <summary>Verifies that the clear-filters button is enabled as soon as the operator types, not only once the debounce fires.</summary>
[Fact]
public void ClearFilters_IsEnabledAsSoonAsTheOperatorTypes_NotOnlyOnceTheDebounceFires()
{
@@ -294,6 +309,7 @@ public class DeploymentsServerPagingTests : BunitContext
Assert.False(cut.Find("[data-test=\"deploy-clear-filters\"]").HasAttribute("disabled"));
}
/// <summary>Verifies that disposing the page with an armed filter timer does not issue a query.</summary>
[Fact]
public void DisposeWithAnArmedFilterTimer_DoesNotQuery()
{
@@ -311,6 +327,7 @@ public class DeploymentsServerPagingTests : BunitContext
// ── Rendering ─────────────────────────────────────────────────────────────
/// <summary>Verifies that the instance name comes from the joined server row rather than a client-side lookup table.</summary>
[Fact]
public void InstanceName_ComesFromTheJoinedRow_NotAClientSideLookupTable()
{
@@ -45,7 +45,7 @@ public class FlowDebuggerPageTests : BunitContext
};
/// <summary>
/// #37: the MesMoveOut shape — a REQUIRED array of objects — which the old flat text-box pane
/// The MesMoveOut shape — a REQUIRED array of objects — which the old flat text-box pane
/// could not take at all (the array reached the site empty).
/// </summary>
private static readonly ApiMethod MesMoveOut = new("MesMoveOut", "return 1;")
@@ -69,6 +69,7 @@ public class FlowDebuggerPageTests : BunitContext
],
[new("n1", "n2", false), new("n2", "n3", false)]);
/// <summary>Verifies that a refresh merge keeps the replay row order and then appends rows that exist only in the live run.</summary>
[Fact]
public void RefreshMerge_UsesReplayOrderThenLiveOnlyRows()
{
@@ -140,10 +141,14 @@ public class FlowDebuggerPageTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Initializes the provider with the authentication state to return.</summary>
/// <param name="state">The authentication state to return.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(_state);
}
/// <summary>Verifies that the page renders the toolbar and an empty flow for a deployer.</summary>
[Fact]
public void Renders_ForDeployer_WithToolbarAndEmptyFlow()
{
@@ -164,6 +169,7 @@ public class FlowDebuggerPageTests : BunitContext
// ---- F3: Replay opens to the OperationalAudit roles; Live stays Deployer-only ----------------
/// <summary>Verifies that the page renders replay-only for a viewer, with no live run controls.</summary>
[Fact]
public void Renders_ForViewer_ReplayOnly_WithNoLiveControls()
{
@@ -186,6 +192,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Empty(cut.FindAll("select[data-test='flow-method-select']"));
}
/// <summary>Verifies that a viewer can load a replay but gets no retry or discard actions on a parked node.</summary>
[Fact]
public void Viewer_LoadsAReplay_ButGetsNoRetryOrDiscardOnAParkedNode()
{
@@ -209,6 +216,7 @@ public class FlowDebuggerPageTests : BunitContext
_flow.Received(1).ReplayAsync(root, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a viewer drilling in with a method id does not open the live run surface.</summary>
[Fact]
public void Viewer_DrillInWithAMethodId_DoesNotOpenTheLiveRunSurface()
{
@@ -225,6 +233,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.NotNull(cut.Find("input[data-test='flow-execution-id']"));
}
/// <summary>Verifies that choosing a method shows the predicted graph and the inputs form.</summary>
[Fact]
public void ChoosingAMethod_ShowsThePredictedGraph_AndTheInputsForm()
{
@@ -236,7 +245,7 @@ public class FlowDebuggerPageTests : BunitContext
cut.WaitForAssertion(() => Assert.Equal(3, cut.FindAll("g[data-test='flow-node']").Count));
Assert.Equal("Predicted", cut.Find("[data-test='flow-state']").TextContent.Trim());
Assert.NotNull(cut.Find("[data-test='flow-predicted-note']"));
// #37: the method pane is the shared ParameterValueForm (typed inputs by field id), and
// The method pane is the shared ParameterValueForm (typed inputs by field id), and
// Run is gated on its validity — MachineCode is required, so Run stays off until it is set.
Assert.NotEmpty(cut.FindAll("[data-test='flow-method-params']"));
Assert.NotNull(cut.Find("#param-MachineCode"));
@@ -246,6 +255,7 @@ public class FlowDebuggerPageTests : BunitContext
cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
}
/// <summary>Verifies that a run sends the typed inputs and renders the returned graph.</summary>
[Fact]
public void Run_SendsTypedInputs_AndRendersTheReturnedGraph()
{
@@ -274,6 +284,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Contains("POST /api/DelmiaRecipeDownload", cut.Find("[data-test='flow-step-title']").TextContent);
}
/// <summary>Verifies that a standby node refusal is shown verbatim in an inline alert.</summary>
[Fact]
public void Run_StandbyNodeRefusal_ShowsInlineAlertVerbatim()
{
@@ -293,6 +304,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal("Failed", cut.Find("[data-test='flow-state']").TextContent.Trim());
}
/// <summary>Verifies that an execution id query parameter triggers a replay on the first render.</summary>
[Fact]
public void ExecutionIdQueryParameter_TriggersReplayOnFirstRender()
{
@@ -310,6 +322,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Contains("btn-primary", cut.Find("button[data-test='flow-mode-replay']").ClassName);
}
/// <summary>Verifies that a run with no site steps and no child execution id shows the site-not-traced label.</summary>
[Fact]
public void Run_WithNoSiteStepsAndNoChildId_ShowsSiteNotTracedLabel()
{
@@ -325,7 +338,7 @@ public class FlowDebuggerPageTests : BunitContext
cut.WaitForElement("[data-test='flow-site-not-traced']");
}
// ---- Node actions (follow-ups plan 2026-09-06, Task 8): Retry / Discard on a Parked node -------
// ---- Node actions (follow-ups plan 2026-09-06): Retry / Discard on a Parked node -------
/// <summary>Same events as <see cref="OutboundOutcome"/> but the cached call ends Parked; the second
/// outcome shares the event objects (stable ids, as real audit rows are) and adds a Delivered resolve.</summary>
@@ -379,6 +392,7 @@ public class FlowDebuggerPageTests : BunitContext
return cut;
}
/// <summary>Verifies that retrying a parked node confirms, relays the retry and re-polls until the node is no longer parked.</summary>
[Fact]
public void ParkedNode_Retry_ConfirmsRelaysAndRepollsUntilNotParked()
{
@@ -401,6 +415,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Empty(cut.FindAll("button[data-test='flow-step-retry']")); // the selected node is no longer parked
}
/// <summary>Verifies that discarding a parked node uses a danger confirmation and does not poll when the confirmation is refused.</summary>
[Fact]
public void ParkedNode_Discard_UsesDangerConfirm_AndDoesNotPollWhenRefused()
{
@@ -420,6 +435,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.NotNull(cut.Find("button[data-test='flow-step-retry']"));
}
/// <summary>Verifies that declining the confirmation for a parked node never relays the action.</summary>
[Fact]
public void ParkedNode_ConfirmDeclined_NeverRelays()
{
@@ -436,6 +452,7 @@ public class FlowDebuggerPageTests : BunitContext
_flow.DidNotReceive().ReplayAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that an action on a parked node at an out-of-scope site is refused before any confirmation.</summary>
[Fact]
public void ParkedNode_OutOfScopeSite_RefusedBeforeConfirm()
{
@@ -452,7 +469,7 @@ public class FlowDebuggerPageTests : BunitContext
_flow.DidNotReceive().RetryOutboundAsync(Arg.Any<FlowStep>(), Arg.Any<CancellationToken>());
}
// ---- Outbound calls (2026-09-06 plan, Task 9): pending refresh after a Live run ----------
// ---- Outbound calls (2026-09-06 plan): pending refresh after a Live run ----------
/// <summary>
/// Inbound → route → site run → cached API call; the call is Pending unless <paramref name="resolved"/>.
@@ -498,6 +515,7 @@ public class FlowDebuggerPageTests : BunitContext
};
}
/// <summary>Verifies that a live run with a pending outbound call polls the replay until the call reaches a terminal state.</summary>
[Fact]
public void LiveRun_WithPendingOutbound_PollsReplayUntilTerminal()
{
@@ -560,6 +578,7 @@ public class FlowDebuggerPageTests : BunitContext
return new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null);
}
/// <summary>Verifies that a pending refresh keeps the steps the live run already had when the replay lags behind telemetry.</summary>
[Fact]
public void PendingRefresh_KeepsStepsTheLiveRunAlreadyHad_WhenReplayLagsBehindTelemetry()
{
@@ -593,6 +612,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Contains(cut.FindAll("[data-test='flow-node-pill']"), p => p.TextContent.Trim() == "Ok");
}
/// <summary>Verifies that a live run with no pending step never polls.</summary>
[Fact]
public void LiveRun_WithNoPendingStep_NeverPolls()
{
@@ -611,6 +631,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Empty(cut.FindAll("[data-test='flow-pending-count']"));
}
/// <summary>Verifies that replay mode never polls but the refresh button replays once.</summary>
[Fact]
public void ReplayMode_NeverPolls_ButRefreshButtonReplaysOnce()
{
@@ -665,6 +686,7 @@ public class FlowDebuggerPageTests : BunitContext
_flow.PredictScriptAsync(7, Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(ThreeNodePrediction());
}
/// <summary>Verifies that the script entry lists scripts with their trigger labels once a site and instance are chosen.</summary>
[Fact]
public void ScriptEntry_ListsScriptsWithTriggerLabels_AfterSiteAndInstance()
{
@@ -679,6 +701,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Empty(cut.FindAll("select[data-test='flow-attribute-select']"));
}
/// <summary>Verifies that a write-kind script shows a value field for the trigger attribute and gates Run on it.</summary>
[Fact]
public void ScriptEntry_WriteKind_ShowsTheValueField_ForTheTriggerAttribute_AndGatesRunOnIt()
{
@@ -697,6 +720,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
/// <summary>Verifies that a call-kind script shows the parameter form and runs with the entered parameters.</summary>
[Fact]
public void ScriptEntry_CallKind_ShowsTheParameterForm_AndRunsWithParameters()
{
@@ -717,6 +741,7 @@ public class FlowDebuggerPageTests : BunitContext
_ = _flow.Received(1).RunScriptAsync(7, "Manual", Arg.Is<JsonElement?>(p => p != null && p.Value.GetProperty("n").GetInt64() == 3), null, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a call-kind script without parameters says so and can be run.</summary>
[Fact]
public void ScriptEntry_CallKindWithoutParameters_SaysSo_AndCanRun()
{
@@ -731,6 +756,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
/// <summary>Verifies that a deep link with an instance and a script preselects both and shows the prediction.</summary>
[Fact]
public void DeepLink_InstanceAndScript_PreselectsAndPredicts()
{
@@ -774,6 +800,7 @@ public class FlowDebuggerPageTests : BunitContext
return cut;
}
/// <summary>Verifies that switching between call-kind scripts resets the parameter form and its validity.</summary>
[Fact]
public void ScriptEntry_SwitchingBetweenCallKindScripts_ResetsTheFormAndValidity()
{
@@ -792,6 +819,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
/// <summary>Verifies that switching from an invalid form to a script without parameters enables Run.</summary>
[Fact]
public void ScriptEntry_SwitchingFromAnInvalidFormToANoParameterScript_EnablesRun()
{
@@ -805,6 +833,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
/// <summary>Verifies that the instance picker shows areas collapsed until they are expanded.</summary>
[Fact]
public void ScriptEntry_InstancePicker_ShowsAreasCollapsed_UntilExpanded()
{
@@ -838,6 +867,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal(["— choose —", "Manual (Call)", "OnFlag (ValueChange: Flag)", "Tick (Interval)"], options);
}
/// <summary>Verifies that a deep link with an instance id shows the instance name in the picker.</summary>
[Fact]
public void ScriptEntry_DeepLink_InstanceId_ShowsItsNameInThePicker()
{
@@ -913,6 +943,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.False(cut.Find("[data-test='flow-instance-input']").HasAttribute("disabled")));
}
/// <summary>Verifies that changing the site clears the picked instance.</summary>
[Fact]
public void ScriptEntry_SiteChange_ClearsThePickedInstance()
{
@@ -940,7 +971,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal(["— choose —"], cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList());
}
// ---- Step logs (plan 2026-09-09 D5/D7, Task 13): status-strip counts, truncation notes, cross-step hint ----
// ---- Step logs (plan 2026-09-09 D5/D7): status-strip counts, truncation notes, cross-step hint ----
/// <summary>One <c>ScriptTrace.ScriptLog</c> row — a line the builder folds onto a step, never a node of its own.</summary>
private static AuditEvent LogLine(Guid execution, string level, int ms, Guid? stepEventId = null, string message = "hello") =>
@@ -995,6 +1026,7 @@ public class FlowDebuggerPageTests : BunitContext
return cut;
}
/// <summary>Verifies that the status strip shows log counts by level.</summary>
[Fact]
public void StatusStrip_ShowsLogCountsByLevel()
{
@@ -1005,6 +1037,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal("1 errors · 2 warnings · 3 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim());
}
/// <summary>Verifies that the status strip omits log counts when the run logged nothing.</summary>
[Fact]
public void StatusStrip_OmitsLogCounts_WhenTheRunLoggedNothing()
{
@@ -1017,6 +1050,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Empty(cut.FindAll("[data-test='flow-unattached-logs']"));
}
/// <summary>Verifies that the status strip shows the inline log truncation badge.</summary>
[Fact]
public void StatusStrip_ShowsTheInlineLogTruncationBadge()
{
@@ -1028,6 +1062,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Contains("inline log lines capped", cut.Find("[data-test='flow-logs-truncated']").TextContent);
}
/// <summary>Verifies that the status strip notes log lines that could not be placed on a step.</summary>
[Fact]
public void StatusStrip_NotesLinesThatCouldNotBePlacedOnAStep()
{
@@ -1040,6 +1075,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal("0 errors · 0 warnings · 1 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim());
}
/// <summary>Verifies that the state chip stays completed when the only errors are log lines.</summary>
[Fact]
public void StateChip_StaysCompleted_WhenTheOnlyErrorsAreLogLines()
{
@@ -1052,6 +1088,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal("2 errors · 0 warnings · 0 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim());
}
/// <summary>Verifies that the step detail log hint selects the sibling step that carries the other log lines.</summary>
[Fact]
public void StepDetail_LogHint_SelectsTheSiblingStepCarryingTheOtherLines()
{
@@ -1134,6 +1171,7 @@ public class FlowDebuggerPageTests : BunitContext
return (run, refreshed, root);
}
/// <summary>Verifies that refreshing outcomes clears the inline cap badge once it merges in more log rows.</summary>
[Fact]
public void RefreshOutcomes_ClearsTheInlineCapBadge_OnceItMergesInMoreLogRows()
{
@@ -1150,6 +1188,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal("0 errors · 1 warnings · 1 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim());
}
/// <summary>Verifies that refreshing outcomes keeps the inline cap badge when the re-read brings back no new lines.</summary>
[Fact]
public void RefreshOutcomes_KeepsTheInlineCapBadge_WhenTheReReadBringsBackNoNewLines()
{
@@ -1170,6 +1209,7 @@ public class FlowDebuggerPageTests : BunitContext
// ---- CountLogs: the level buckets, straight ---------------------------------------------------
/// <summary>Verifies that log counting buckets lines by level case-insensitively and counts everything else as info.</summary>
[Fact]
public void CountLogs_BucketsByLevel_CaseInsensitively_WithEverythingElseAsInfo()
{
@@ -1189,6 +1229,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal((1, 1, 3, 5), FlowDebuggerPage.CountLogs(graph));
}
/// <summary>Verifies that counting logs for a null graph yields all zeroes.</summary>
[Fact]
public void CountLogs_NullGraph_IsAllZeroes()
{
@@ -1197,7 +1238,7 @@ public class FlowDebuggerPageTests : BunitContext
/// <summary>
/// Waits for the method pane's <c>ParameterValueForm</c> and fills the fixture method's one
/// required field, so Run is enabled (#37 gates Run on the form's validity).
/// required field, so Run is enabled (Run is gated on the form's validity).
/// </summary>
private static void FillRequiredMethodInput(IRenderedComponent<FlowDebuggerPage> cut)
{
@@ -1205,8 +1246,9 @@ public class FlowDebuggerPageTests : BunitContext
cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
}
// ── #37: structured method parameters ───────────────────────────────────────────────────
// ── Structured method parameters ───────────────────────────────────────────────────
/// <summary>Verifies that an array of objects entered in the item editors round-trips into the run payload.</summary>
[Fact]
public void MethodEntry_ArrayOfObjects_RoundTripsFromItemEditorsToTheRunPayload()
{
@@ -1241,6 +1283,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal("WO-2", orders[1].GetProperty("WorkOrderNumber").GetString());
}
/// <summary>Verifies that leaving a required list item field empty keeps Run disabled.</summary>
[Fact]
public void MethodEntry_RequiredListItemField_LeftEmpty_KeepsRunDisabled()
{
@@ -1258,6 +1301,8 @@ public class FlowDebuggerPageTests : BunitContext
cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
}
/// <summary>Verifies that an invalid raw JSON document blocks Run and shows the error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MethodEntry_RawJson_InvalidDocument_BlocksRun_AndShowsTheError()
{
@@ -1289,6 +1334,8 @@ public class FlowDebuggerPageTests : BunitContext
_ = _flow.DidNotReceive().RunMethodAsync(Arg.Any<int>(), Arg.Any<JsonElement?>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a valid raw JSON document is sent verbatim and survives switching back to the form.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MethodEntry_RawJson_ValidDocument_IsSentVerbatim_AndSurvivesTheSwitchBackToForm()
{
@@ -1322,6 +1369,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal("P-1", sent.Value.GetProperty("WorkOrders")[0].GetProperty("PartNumber").GetString());
}
/// <summary>Verifies that switching methods resets the form and its validity.</summary>
[Fact]
public void MethodEntry_SwitchingMethods_ResetsTheFormAndItsValidity()
{
@@ -1339,6 +1387,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
/// <summary>Verifies that a persisted value tree is restored into the form.</summary>
[Fact]
public void MethodEntry_PersistedValueTree_IsRestoredIntoTheForm()
{
@@ -1353,11 +1402,12 @@ public class FlowDebuggerPageTests : BunitContext
cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
}
/// <summary>Verifies that a legacy flat string entry that no longer validates is dropped.</summary>
[Fact]
public void MethodEntry_LegacyFlatStringEntry_ThatNoLongerValidates_IsDropped()
{
RegisterServices();
// Pre-#37 entries stored every field as a string; "7" is not an Integer, so the whole
// Earlier entries stored every field as a string; "7" is not an Integer, so the whole
// entry is discarded rather than painted into the form as an error.
JSInterop.Setup<string?>("localStorage.getItem", "flowDebugger.inputs.4")
.SetResult("{\"MachineCode\":\"Z9\",\"Qty\":\"7\"}");
@@ -5,16 +5,16 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Flow Debugger outbound-calls plan (2026-09-06) Task 8: the step detail panel's Lifecycle tab,
/// outbound "Where this ran" fields and kind-specific Input tab labels. Follow-ups plan (2026-09-06)
/// Task 6: report deep links from a correlated outbound step and Retry/Discard on a parked one (D2/D5).
/// Step-logs plan (2026-09-09) Task 12: the Log tab — exception block, level-filtered lines and the
/// cross-step hint (design D8).
/// Tests for the Flow Debugger step detail panel: the Lifecycle tab, outbound "Where this ran" fields
/// and kind-specific Input tab labels; report deep links from a correlated outbound step and
/// Retry/Discard on a parked one (D2/D5); and the Log tab — exception block, level-filtered lines and
/// the cross-step hint (design D8).
/// </summary>
public class FlowStepDetailTests : BunitContext
{
private static readonly DateTimeOffset T0 = new(2026, 9, 6, 12, 0, 0, TimeSpan.Zero);
/// <summary>Initializes the test context with loose JS interop so the Log tab's clipboard calls are accepted.</summary>
public FlowStepDetailTests()
{
// The Log tab's Copy buttons call navigator.clipboard through IJSRuntime.
@@ -59,6 +59,7 @@ public class FlowStepDetailTests : BunitContext
new(Guid.NewGuid(), "CachedResolve", terminalStatus, T0.AddMilliseconds(4100), "node-b", null, null),
];
/// <summary>Verifies that the Lifecycle tab renders its rows in time order and shows who delivered the operation.</summary>
[Fact]
public void LifecycleTab_RendersRowsInTimeOrder_AndDeliveredBy()
{
@@ -84,6 +85,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Empty(cut.FindAll("[data-test=flow-step-parked-hint]"));
}
/// <summary>Verifies that a step without lifecycle rows hides the Lifecycle tab.</summary>
[Fact]
public void NoLifecycle_HidesTheTab()
{
@@ -93,6 +95,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Equal("Input", cut.Find("[data-test=flow-step-tab-input]").TextContent.Trim());
}
/// <summary>Verifies that a parked notification step shows the hint, the list label and a parked badge.</summary>
[Fact]
public void ParkedNotify_ShowsHint_AndListLabel_AndParkedBadge()
{
@@ -115,6 +118,10 @@ public class FlowStepDetailTests : BunitContext
Assert.Contains("Site Calls / Notification", cut.Find("[data-test=flow-step-parked-hint]").TextContent);
}
/// <summary>Verifies that each outbound step kind labels its Input tab and target field appropriately.</summary>
/// <param name="kind">The outbound step kind under test.</param>
/// <param name="inputLabel">The expected label of the Input tab.</param>
/// <param name="targetLabel">The expected label of the target field.</param>
[Theory]
[InlineData(FlowStepKind.ApiCall, "Request", "System")]
[InlineData(FlowStepKind.DbCall, "SQL + parameters", "Connection")]
@@ -127,6 +134,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Contains(targetLabel, cut.Find("[data-test=flow-step-where]").TextContent);
Assert.Equal("Pending", cut.Find("[data-test=flow-step-outcome]").TextContent.Trim());
}
/// <summary>Verifies that an outbound step with a correlation id links to its report page.</summary>
[Fact]
public void OutboundStepWithCorrelation_LinksToTheReportPage()
{
@@ -149,6 +157,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Empty(cut.FindAll("[data-test=open-report]"));
}
/// <summary>Verifies that a parked step with callbacks shows Retry and Discard actions.</summary>
[Fact]
public void ParkedStep_WithCallbacks_ShowsRetryAndDiscard()
{
@@ -187,6 +196,7 @@ public class FlowStepDetailTests : BunitContext
Assert.True(cut.Find("button[data-test=flow-step-discard]").HasAttribute("disabled"));
}
/// <summary>Verifies that a parked step without callbacks shows only the hint link.</summary>
[Fact]
public void ParkedStep_WithoutCallbacks_ShowsOnlyTheHintLink()
{
@@ -205,6 +215,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Equal($"/notifications/report?correlationId={c:N}", hintLink!.GetAttribute("href"));
}
/// <summary>Verifies that a successful step never shows Retry or Discard actions.</summary>
[Fact]
public void OkStep_NeverShowsActions()
{
@@ -218,6 +229,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Empty(cut.FindAll("[data-test=flow-step-discard]"));
}
/// <summary>Verifies that a step with no log lines and no error detail hides the Log tab.</summary>
[Fact]
public void NoLinesAndNoDetail_HidesTheLogTab()
{
@@ -225,6 +237,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Empty(cut.FindAll("[data-test=flow-step-tab-log]"));
}
/// <summary>Verifies that the Log tab shows a count of its lines.</summary>
[Fact]
public void LogTab_CountsItsLines()
{
@@ -239,6 +252,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Equal("Log (3)", cut.Find("[data-test=flow-step-tab-log]").TextContent.Trim());
}
/// <summary>Verifies that a failed step with error detail opens on the Log tab and renders the exception block.</summary>
[Fact]
public void FailedStepWithDetail_OpensOnLog_AndRendersTheExceptionBlock()
{
@@ -254,10 +268,11 @@ public class FlowStepDetailTests : BunitContext
Assert.NotNull(cut.Find("[data-test=flow-step-exception-copy]"));
}
/// <summary>Verifies that the exception block header names the innermost exception type in full for a wrapped exception.</summary>
[Fact]
public void WrappedException_TheBlockHeaderNamesTheINNERMOSTType_InFull()
{
// Final review M1: the panel used to parse the OUTERMOST exception of a " ---> " chain
// The panel used to parse the OUTERMOST exception of a " ---> " chain
// while the graph node's subtitle showed the innermost — the two now share
// ExceptionHeader, which always follows the chain to its end. The panel still shows the
// type with its namespace (only the node badge shortens it), and the raw detail is
@@ -275,6 +290,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Contains("System.AggregateException", exc.QuerySelector("pre")!.TextContent);
}
/// <summary>Verifies that a successful step with log lines opens on the Input tab.</summary>
[Fact]
public void OkStepWithLines_OpensOnInput()
{
@@ -286,6 +302,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Empty(cut.FindAll("[data-test=flow-step-exception]"));
}
/// <summary>Verifies that the warning filter leaves only warning and error rows.</summary>
[Fact]
public void WarnFilter_LeavesOnlyWarnAndErrorRows()
{
@@ -311,6 +328,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Equal(4, cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]").Count);
}
/// <summary>Verifies that the hint names another step of the same execution and selects it when clicked.</summary>
[Fact]
public void Hint_NamesAnotherStepOfTheSameExecution_AndSelectsIt()
{
@@ -336,6 +354,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Same(other, picked);
}
/// <summary>Verifies that a hint without a select callback is rendered as plain text.</summary>
[Fact]
public void Hint_WithoutASelectCallback_IsPlainText()
{
@@ -352,6 +371,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Null(hint.QuerySelector("button"));
}
/// <summary>Verifies that the see-exception link in the output alert switches to the Log tab.</summary>
[Fact]
public void OutputAlert_SeeExceptionLink_SwitchesToTheLogTab()
{
@@ -367,6 +387,7 @@ public class FlowStepDetailTests : BunitContext
Assert.NotNull(cut.Find("[data-test=flow-step-exception]"));
}
/// <summary>Verifies that a runtime-sourced log line shows its source and any attached exception.</summary>
[Fact]
public void RuntimeSourcedLine_ShowsItsSource_AndAnAttachedException()
{
@@ -384,6 +405,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Contains("System.TimeoutException", row.QuerySelector("details pre")!.TextContent);
}
/// <summary>Verifies that switching steps keeps the selected tab when it is still valid for the new step.</summary>
[Fact]
public void SwitchingSteps_KeepsAStillValidTab()
{
@@ -400,6 +422,7 @@ public class FlowStepDetailTests : BunitContext
Assert.DoesNotContain("active", cut.Find("[data-test=flow-step-tab-input]").ClassName);
}
/// <summary>Verifies that refreshing the same step keeps the selected tab and the level filter.</summary>
[Fact]
public void RefreshingTheSameStep_KeepsTheTabAndTheLevelFilter()
{
@@ -19,7 +19,7 @@ using InstanceConfigurePage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.D
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Bundle D drill-in test (#23 M7-T12) for the Instance Configure page. The
/// Drill-in test for the Instance Configure page. The
/// chip routes operators into the central Audit Log pre-filtered by
/// <c>?instance={Instance.UniqueName}</c>. Instance is UI-only on the filter
/// bar (the repository filter contract has no instance column), so the page
@@ -32,6 +32,7 @@ public class InstanceConfigureAuditDrillinTests : BunitContext
Substitute.For<ITemplateEngineRepository>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
/// <summary>Initializes the test context and registers the services the Instance Configure page depends on.</summary>
public InstanceConfigureAuditDrillinTests()
{
// Loose JS interop because shared components on the page render
@@ -65,6 +66,7 @@ public class InstanceConfigureAuditDrillinTests : BunitContext
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
}
/// <summary>Verifies that the page renders a recent audit activity link carrying the instance unique name.</summary>
[Fact]
public void Page_HasRecentAuditActivityLink_WithInstanceUniqueName()
{
@@ -114,6 +116,7 @@ public class InstanceConfigureAuditDrillinTests : BunitContext
/// protocol == "OpcUa". Previously BuildTestableRows filtered to OPC UA
/// only, leaving the button greyed for MxGateway bindings.
/// </summary>
/// <param name="protocol">The data connection protocol of the bound attribute.</param>
[Theory]
[InlineData("MxGateway")]
[InlineData("OpcUa")]
@@ -33,6 +33,7 @@ public class InstanceConfigureBindingBehaviourTests : BunitContext
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
/// <summary>Creates the test class and registers the stubbed services the page needs.</summary>
public InstanceConfigureBindingBehaviourTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
@@ -85,6 +86,7 @@ public class InstanceConfigureBindingBehaviourTests : BunitContext
new() { CanonicalName = canonical, DataType = "Boolean", DataSourceReference = effective ?? dflt,
DefaultDataSourceReference = dflt, BoundDataConnectionId = conn };
/// <summary>Verifies that binding rows come from the flattened configuration with composed members grouped.</summary>
[Fact]
public void Rows_ComeFromFlattenedConfiguration_ComposedMembersGrouped()
{
@@ -110,6 +112,7 @@ public class InstanceConfigureBindingBehaviourTests : BunitContext
});
}
/// <summary>Verifies that a flatten failure falls back to the direct attributes and shows a banner.</summary>
[Fact]
public void FlattenFailure_FallsBackToDirectAttributes_WithBanner()
{
@@ -128,6 +131,8 @@ public class InstanceConfigureBindingBehaviourTests : BunitContext
});
}
/// <summary>Verifies that applying a connection to a mixed group keeps each member connection and that save posts the composed rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyOnMixedConnectionGroup_KeepsEachMembersConnection_AndSavePostsComposedRows()
{
@@ -9,6 +9,7 @@ public class InstanceConfigureBindingGroupsTests
{
private static string Markup => InstanceConfigureBindingRowsTests.ReadMarkup();
/// <summary>Verifies that a group header has a connection selector, an object selector and an Apply button.</summary>
[Fact]
public void GroupHeader_HasConnectionObjectAndApply()
{
@@ -21,6 +22,7 @@ public class InstanceConfigureBindingGroupsTests
Assert.Contains("BindingGroupBuilder.ApplyObject(", m);
}
/// <summary>Verifies that a group header shows unbound and mixed status pills.</summary>
[Fact]
public void GroupHeader_ShowsUnboundAndMixedPills()
{
@@ -30,6 +32,7 @@ public class InstanceConfigureBindingGroupsTests
Assert.Contains("data-test=\"binding-total-unbound\"", m);
}
/// <summary>Verifies that the toolbar offers an unbound-only filter and an expand-all control.</summary>
[Fact]
public void Toolbar_HasUnboundFilterAndExpandAll()
{
@@ -39,18 +42,21 @@ public class InstanceConfigureBindingGroupsTests
Assert.Contains("data-test=\"binding-bulk-select\"", m); // existing all-groups connection select retained
}
/// <summary>Verifies that a composed member with a bare default binding is flagged.</summary>
[Fact]
public void ComposedBareDefault_IsFlagged()
{
Assert.Contains("data-test=\"binding-bare-default\"", Markup);
}
/// <summary>Verifies that the old flat bindings table is no longer rendered.</summary>
[Fact]
public void OldFlatTable_IsGone()
{
Assert.DoesNotContain("_bindingDataSourceAttrs", Markup);
}
/// <summary>Verifies that the per-row Browse button is retained.</summary>
[Fact]
public void PerRowBrowseButton_IsRetained()
{
@@ -8,6 +8,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// </summary>
public class InstanceConfigureBindingRowsTests
{
/// <summary>Reads the InstanceConfigure page markup from the source tree.</summary>
/// <returns>The full text of <c>InstanceConfigure.razor</c>.</returns>
internal static string ReadMarkup()
{
var dir = AppContext.BaseDirectory;
@@ -18,6 +20,7 @@ public class InstanceConfigureBindingRowsTests
private static string Markup => ReadMarkup();
/// <summary>Verifies that binding rows come from the flattened configuration rather than the template attributes.</summary>
[Fact]
public void BindingRows_ComeFromFlattenedConfiguration_NotTemplateAttributes()
{
@@ -29,6 +32,7 @@ public class InstanceConfigureBindingRowsTests
Assert.Contains("DefaultDataSourceReference", m);
}
/// <summary>Verifies that binding rows fall back to direct attributes with a warning banner when flattening fails.</summary>
[Fact]
public void BindingRows_FallBackToDirectAttributes_WithBanner_WhenFlattenFails()
{
@@ -37,6 +41,7 @@ public class InstanceConfigureBindingRowsTests
Assert.Contains("data-test=\"binding-source-warning\"", m);
}
/// <summary>Verifies that save and test cover every binding row.</summary>
[Fact]
public void SaveAndTest_CoverEveryRow()
{
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// MV-14: the Instance Configure attribute-override panel uses the shared
/// The Instance Configure attribute-override panel uses the shared
/// <c>AttributeListEditor</c> for a List attribute (whole-list replacement; the
/// element type is fixed by the base attribute, so the type select is hidden via
/// <c>ShowElementType="false"</c>). Loading an existing override decodes its JSON
@@ -30,6 +30,7 @@ public class InstanceConfigureListOverrideTests
}
}
/// <summary>Verifies that list override, reveals shared editor, with element type hidden.</summary>
[Fact]
public void ListOverride_RevealsSharedEditor_WithElementTypeHidden()
{
@@ -45,6 +46,7 @@ public class InstanceConfigureListOverrideTests
Assert.Contains("RowsChanged=\"@(r => OnListRowsChanged(attr.Name, r))\"", markup);
}
/// <summary>Verifies that list override, decodes on load, and encodes on save with guard.</summary>
[Fact]
public void ListOverride_DecodesOnLoad_AndEncodesOnSaveWithGuard()
{
@@ -59,6 +61,7 @@ public class InstanceConfigureListOverrideTests
Assert.Contains("_overrideErrors", markup);
}
/// <summary>Verifies that list override, clear removes the override row.</summary>
[Fact]
public void ListOverride_ClearRemovesTheOverrideRow()
{
@@ -70,6 +73,7 @@ public class InstanceConfigureListOverrideTests
Assert.Contains("HasOverrideRow", markup);
}
/// <summary>Verifies that non list override, keeps single input ux.</summary>
[Fact]
public void NonListOverride_KeepsSingleInputUx()
{
@@ -79,6 +83,7 @@ public class InstanceConfigureListOverrideTests
Assert.Contains("OnOverrideChanged(attr.Name, e)", markup);
}
/// <summary>Verifies that encoded rows, round trip through codec, as the page does.</summary>
[Fact]
public void EncodedRows_RoundTripThroughCodec_AsThePageDoes()
{
@@ -103,6 +108,7 @@ public class InstanceConfigureListOverrideTests
Assert.Equal(json, reEncoded);
}
/// <summary>Verifies that malformed list element, surfaces format exception, for inline error.</summary>
[Fact]
public void MalformedListElement_SurfacesFormatException_ForInlineError()
{
@@ -34,6 +34,7 @@ public class InstanceConfigureMesReportableTests
private static string InstanceConfigureMarkup
=> File.ReadAllText(Path.Combine(DeploymentComponentsRoot, "InstanceConfigure.razor"));
/// <summary>Verifies that the override modal has a tri-state MES-reportable select.</summary>
[Fact]
public void OverrideModal_HasTriStateMesReportableSelect()
{
@@ -57,6 +58,10 @@ public class InstanceConfigureMesReportableTests
Assert.Contains("value=\"false\"", selectBody);
}
/// <summary>Verifies that setting only the MES-reportable flag counts as an override delta.</summary>
/// <param name="overrideJson">The override json.</param>
/// <param name="priority">The priority.</param>
/// <param name="mesReportable">The mes reportable.</param>
[Theory]
// The new term: a reportable-only override is a real delta in BOTH directions
// (false is not "no override" — it pins a reportable template alarm off).
@@ -66,6 +71,10 @@ public class InstanceConfigureMesReportableTests
string? overrideJson, int? priority, bool? mesReportable)
=> Assert.True(InstanceConfigure.HasOverrideDelta(overrideJson, priority, mesReportable));
/// <summary>Verifies that an override with nothing set is not a delta.</summary>
/// <param name="overrideJson">The override json.</param>
/// <param name="priority">The priority.</param>
/// <param name="mesReportable">The mes reportable.</param>
[Theory]
[InlineData(null, null, null)]
[InlineData("", null, null)]
@@ -74,6 +83,10 @@ public class InstanceConfigureMesReportableTests
string? overrideJson, int? priority, bool? mesReportable)
=> Assert.False(InstanceConfigure.HasOverrideDelta(overrideJson, priority, mesReportable));
/// <summary>Verifies that the existing override terms still count as a delta.</summary>
/// <param name="overrideJson">The override json.</param>
/// <param name="priority">The priority.</param>
/// <param name="mesReportable">The mes reportable.</param>
[Theory]
[InlineData("{\"hi\":70}", null, null)]
[InlineData(null, 750, null)]
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Task 25: Instance Configure exposes a "Native Alarm Source Overrides" card —
/// Instance Configure exposes a "Native Alarm Source Overrides" card —
/// per-instance retarget of an inherited native alarm source binding (connection /
/// source reference / filter; blank = inherited). <c>InstanceConfigure</c> is a
/// heavyweight page (7 injected services incl. <c>InstanceService</c> and the
@@ -24,6 +24,7 @@ public class InstanceConfigureNativeAlarmTests
}
}
/// <summary>Verifies that the Instance Configure page has a native alarm source overrides card.</summary>
[Fact]
public void InstanceConfigure_HasNativeAlarmSourceOverridesCard()
{
@@ -33,6 +34,7 @@ public class InstanceConfigureNativeAlarmTests
Assert.Contains("HasNativeOverride", markup);
}
/// <summary>Verifies that a native override row has connection, source reference and filter editors.</summary>
[Fact]
public void NativeOverrideRow_HasConnectionSourceRefAndFilterEditors()
{
@@ -44,6 +46,7 @@ public class InstanceConfigureNativeAlarmTests
Assert.Contains("(inherited)", markup);
}
/// <summary>Verifies that a native override wires the repository upsert and clear operations.</summary>
[Fact]
public void NativeOverride_WiresRepositoryUpsertAndClear()
{
@@ -40,6 +40,7 @@ public class InstanceCreateTemplateFieldTests : BunitContext
private readonly IAuditService _audit = Substitute.For<IAuditService>();
private readonly DialogService _dialog = new();
/// <summary>Initializes a new instance of the <c>InstanceCreateTemplateFieldTests</c> class.</summary>
public InstanceCreateTemplateFieldTests()
{
Services.AddSingleton(_repo);
@@ -144,6 +145,7 @@ public class InstanceCreateTemplateFieldTests : BunitContext
/// <summary>Wrapper that renders InstanceCreate next to a DialogHost, as MainLayout does.</summary>
public sealed class TestHostWrapper : ComponentBase
{
/// <inheritdoc />
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenComponent<InstanceCreatePage>(0);
@@ -158,6 +160,7 @@ public class InstanceCreateTemplateFieldTests : BunitContext
.First(li => li.QuerySelector(".tv-label")!.TextContent.Trim() == label)
.QuerySelector(".tv-toggle")!.Click();
/// <summary>Verifies that template field is read only with choose button and placeholder.</summary>
[Fact]
public void TemplateField_IsReadOnlyWithChooseButton_AndPlaceholder()
{
@@ -169,6 +172,7 @@ public class InstanceCreateTemplateFieldTests : BunitContext
Assert.NotNull(cut.Find("button[data-test='create-template-choose']"));
}
/// <summary>Verifies that template id query prefills field with folder path.</summary>
[Fact]
public void TemplateIdQuery_PrefillsFieldWithFolderPath()
{
@@ -177,6 +181,7 @@ public class InstanceCreateTemplateFieldTests : BunitContext
Assert.Equal("A / B / Deep", cut.Find("input[data-test='create-template']").GetAttribute("value"));
}
/// <summary>Verifies that unknown template id query leaves field empty and shows hint.</summary>
[Fact]
public void UnknownTemplateIdQuery_LeavesFieldEmpty_AndShowsHint()
{
@@ -186,6 +191,7 @@ public class InstanceCreateTemplateFieldTests : BunitContext
Assert.Contains("Template 999 no longer exists", cut.Markup);
}
/// <summary>Verifies that no templates disables choose and links to templates page.</summary>
[Fact]
public void NoTemplates_DisablesChoose_AndLinksToTemplatesPage()
{
@@ -195,6 +201,8 @@ public class InstanceCreateTemplateFieldTests : BunitContext
Assert.Contains("/design/templates", cut.Markup);
}
/// <summary>Verifies that choosing in picker sets template and re previews bindings when site set.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ChoosingInPicker_SetsTemplate_AndRePreviewsBindingsWhenSiteSet()
{
@@ -41,6 +41,7 @@ public class TopologyDisableConfirmLabelTests : BunitContext
private readonly IAuditService _audit = Substitute.For<IAuditService>();
private readonly DialogService _dialog = new();
/// <summary>Initializes the test host with stubbed services and the dialog service.</summary>
public TopologyDisableConfirmLabelTests()
{
Services.AddSingleton(_repo);
@@ -111,6 +112,7 @@ public class TopologyDisableConfirmLabelTests : BunitContext
/// <summary>Wrapper so the page and the dialog host share one render tree.</summary>
public sealed class TestHostWrapper : ComponentBase
{
/// <inheritdoc />
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenComponent<TopologyPage>(0);
@@ -151,6 +153,7 @@ public class TopologyDisableConfirmLabelTests : BunitContext
return (cut, cut.FindAll("div.modal-footer button").ToList());
}
/// <summary>Verifies that the disable confirmation button is labelled Disable rather than Delete.</summary>
[Fact]
public void Disable_confirm_button_says_disable_not_delete()
{
@@ -162,6 +165,7 @@ public class TopologyDisableConfirmLabelTests : BunitContext
Assert.Contains("Cancel", footer.Select(b => b.TextContent.Trim()));
}
/// <summary>Verifies that the disable confirmation body names the instance being disabled.</summary>
[Fact]
public void Disable_confirm_body_names_the_instance()
{
@@ -169,6 +173,7 @@ public class TopologyDisableConfirmLabelTests : BunitContext
Assert.Contains("Disable instance 'Pump-001'?", cut.Find(".modal-body").TextContent);
}
/// <summary>Verifies that the delete confirmation button still says Delete.</summary>
[Fact]
public void Delete_confirm_button_still_says_delete()
{
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
/// <summary>
/// MV-13: the shared <c>AttributeListEditor</c> reveals an element-type select
/// The shared <c>AttributeListEditor</c> reveals an element-type select
/// plus a repeatable list-value editor for structured List attributes. These are
/// real bUnit rendering/interaction tests over the self-contained component, plus
/// structural assertions pinning the TemplateEdit attribute-form wiring (the page
@@ -29,12 +29,13 @@ public class AttributeListEditorTests : BunitContext
private static string TemplateEditMarkup
=> File.ReadAllText(Path.Combine(DesignComponentsRoot, "TemplateEdit.razor"));
// M10 residual: the attribute authoring form moved out of the page into the
// The attribute authoring form moved out of the page into the
// dialog body component hosted by IDialogService.ShowAsync. Submit-side
// encoding stays on the page (it owns TemplateService).
private static string AttributeDialogMarkup
=> File.ReadAllText(Path.Combine(DesignComponentsRoot, "TemplateAttributeDialog.razor"));
/// <summary>Verifies that the editor renders an element type select offering the six valid scalar types.</summary>
[Fact]
public void Editor_RendersElementTypeSelect_WithSixValidScalars()
{
@@ -56,6 +57,7 @@ public class AttributeListEditorTests : BunitContext
Assert.DoesNotContain("List", texts);
}
/// <summary>Verifies that turning off the element type option hides the select.</summary>
[Fact]
public void ShowElementType_False_HidesTheSelect()
{
@@ -66,6 +68,7 @@ public class AttributeListEditorTests : BunitContext
Assert.Throws<ElementNotFoundException>(() => cut.Find("select.form-select"));
}
/// <summary>Verifies that the editor renders one input per row.</summary>
[Fact]
public void Editor_RendersOneInputPerRow()
{
@@ -79,6 +82,7 @@ public class AttributeListEditorTests : BunitContext
Assert.Equal("3", inputs[2].GetAttribute("value"));
}
/// <summary>Verifies that adding an element appends a row and raises the rows-changed callback.</summary>
[Fact]
public void AddElement_AppendsRow_AndRaisesRowsChanged()
{
@@ -95,6 +99,7 @@ public class AttributeListEditorTests : BunitContext
Assert.Equal("", changed[1]);
}
/// <summary>Verifies that removing an element drops its row and raises the rows-changed callback.</summary>
[Fact]
public void RemoveElement_DropsRow_AndRaisesRowsChanged()
{
@@ -112,6 +117,7 @@ public class AttributeListEditorTests : BunitContext
Assert.Equal("b", changed![0]);
}
/// <summary>Verifies that encoded rows round-trip through the codec.</summary>
[Fact]
public void EncodedRows_RoundTripThroughCodec()
{
@@ -123,6 +129,7 @@ public class AttributeListEditorTests : BunitContext
Assert.Equal(new[] { 10, 20 }, list);
}
/// <summary>Verifies that the template edit form reveals the list editor and sends the element type.</summary>
[Fact]
public void TemplateEdit_RevealsListEditor_AndSendsElementType()
{
@@ -13,7 +13,7 @@ using ExternalSystemForm = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Desi
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
/// <summary>
/// Bundle D drill-in test (#23 M7-T12) for the External Systems edit page.
/// Drill-in test for the External Systems edit page.
/// The page-header chip routes operators into the central Audit Log
/// pre-filtered by Target = external-system name. Create mode has nothing
/// to drill into yet, so the link is suppressed.
@@ -22,6 +22,7 @@ public class ExternalSystemFormAuditDrillinTests : BunitContext
{
private readonly IExternalSystemRepository _repo = Substitute.For<IExternalSystemRepository>();
/// <summary>Initializes the test context with a substitute repository and test authentication.</summary>
public ExternalSystemFormAuditDrillinTests()
{
Services.AddSingleton(_repo);
@@ -38,6 +39,7 @@ public class ExternalSystemFormAuditDrillinTests : BunitContext
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
}
/// <summary>Verifies that the edit page shows a recent audit activity link whose target equals the system name.</summary>
[Fact]
public void EditPage_HasRecentAuditActivityLink_WithTargetEqualToSystemName()
{
@@ -57,6 +59,7 @@ public class ExternalSystemFormAuditDrillinTests : BunitContext
});
}
/// <summary>Verifies that the create page shows no recent audit activity link.</summary>
[Fact]
public void CreatePage_HasNoRecentAuditActivityLink()
{
@@ -70,10 +73,11 @@ public class ExternalSystemFormAuditDrillinTests : BunitContext
});
}
/// <summary>Verifies that the form renders a timeout seconds input with the gateway default helper text.</summary>
[Fact]
public void Form_RendersTimeoutSecondsInput_WithGatewayDefaultHelperText()
{
// PLAN-06 Task 12: the per-system TimeoutSeconds column (Task 8) needs a
// The per-system TimeoutSeconds column needs a
// Central UI form field so Design users can actually set it.
var cut = Render<ExternalSystemForm>();
@@ -84,6 +88,7 @@ public class ExternalSystemFormAuditDrillinTests : BunitContext
});
}
/// <summary>Verifies that saving a new system passes its timeout seconds to the repository.</summary>
[Fact]
public void SavingNewSystem_WithTimeoutSeconds_PassesTimeoutSecondsToRepository()
{
@@ -105,6 +110,7 @@ public class ExternalSystemFormAuditDrillinTests : BunitContext
});
}
/// <summary>Verifies that saving an edited system updates the timeout seconds on the existing record.</summary>
[Fact]
public void SavingEditedSystem_UpdatesTimeoutSecondsOnExisting()
{
@@ -25,6 +25,7 @@ public class ExternalSystemsConnectionStringHidingTests : BunitContext
private readonly IInboundApiRepository _inbound = Substitute.For<IInboundApiRepository>();
private readonly IDialogService _dialog = Substitute.For<IDialogService>();
/// <summary>Initializes the test context and registers the services the External Systems page depends on.</summary>
public ExternalSystemsConnectionStringHidingTests()
{
Services.AddSingleton(_repo);
@@ -43,6 +44,7 @@ public class ExternalSystemsConnectionStringHidingTests : BunitContext
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
}
/// <summary>Verifies that the database connections tab does not render the connection string.</summary>
[Fact]
public void DbConnectionsTab_DoesNotRenderConnectionString()
{
@@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
/// <summary>
/// M9-T31 (+#261 follow-up): the raw-JSON escape hatch in
/// The raw-JSON escape hatch in
/// <see cref="ParameterValueForm"/> (the fallback for an unresolved <c>$ref</c> /
/// unknown-type node) is a Monaco <c>json</c> editor wired with THAT FIELD's own
/// sub-schema — not the whole root object schema — so each field's editor
@@ -22,6 +22,7 @@ public class ParameterValueFormMonacoSchemaTests : BunitContext
{
private readonly ISchemaLibraryQueryService _library = Substitute.For<ISchemaLibraryQueryService>();
/// <summary>Initializes a new instance of the <see cref="ParameterValueFormMonacoSchemaTests"/> class.</summary>
public ParameterValueFormMonacoSchemaTests()
{
// The escape-hatch surface renders a MonacoEditor whose JS interop is not
@@ -33,6 +34,7 @@ public class ParameterValueFormMonacoSchemaTests : BunitContext
Services.AddSingleton(_library);
}
/// <summary>Verifies the case: escape Hatch, renders Monaco Json Editor, not Plain Textarea.</summary>
[Fact]
public void EscapeHatch_RendersMonacoJsonEditor_NotPlainTextarea()
{
@@ -57,6 +59,7 @@ public class ParameterValueFormMonacoSchemaTests : BunitContext
Assert.Empty(cut.FindAll("textarea"));
}
/// <summary>Verifies the case: escape Hatch, monaco Editor, receives Field Sub Schema, not Root Schema.</summary>
[Fact]
public void EscapeHatch_MonacoEditor_ReceivesFieldSubSchema_NotRootSchema()
{
@@ -106,6 +109,7 @@ public class ParameterValueFormMonacoSchemaTests : BunitContext
Assert.DoesNotContain("$ref", jsonSchema!);
}
/// <summary>Verifies the case: escape Hatch, with Resolvable Sibling Object, scopes Field Editor To Its Own Fragment.</summary>
[Fact]
public void EscapeHatch_WithResolvableSiblingObject_ScopesFieldEditorToItsOwnFragment()
{
@@ -143,6 +147,7 @@ public class ParameterValueFormMonacoSchemaTests : BunitContext
Assert.DoesNotContain("\"id\"", jsonSchema!);
}
/// <summary>Verifies the case: multiple Escape Hatch Fields, each Editor Receives Its Own Field Fragment, not Root Schema.</summary>
[Fact]
public void MultipleEscapeHatchFields_EachEditorReceivesItsOwnFieldFragment_NotRootSchema()
{
@@ -182,6 +187,7 @@ public class ParameterValueFormMonacoSchemaTests : BunitContext
}
}
/// <summary>Verifies the case: multiple Escape Hatch Fields, do Not Leak AResolvable Siblings Shape.</summary>
[Fact]
public void MultipleEscapeHatchFields_DoNotLeakAResolvableSiblingsShape()
{
@@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
/// <summary>
/// M9-T30: <c>ParameterValueForm</c> renders typed nested inputs for object and
/// <c>ParameterValueForm</c> renders typed nested inputs for object and
/// list parameters (replacing the raw-JSON textarea fallback), driven by the
/// declared schema (with <c>{"$ref":"lib:Name"}</c> resolved via
/// <see cref="ISchemaLibraryQueryService"/>), with per-field validation, and
@@ -19,6 +19,7 @@ public class ParameterValueFormTests : BunitContext
{
private readonly ISchemaLibraryQueryService _library = Substitute.For<ISchemaLibraryQueryService>();
/// <summary>Initializes a new instance of the <c>ParameterValueFormTests</c> class.</summary>
public ParameterValueFormTests()
{
// Default: empty library (no $ref entries). Individual tests override.
@@ -56,6 +57,7 @@ public class ParameterValueFormTests : BunitContext
}
""";
/// <summary>Verifies that an object parameter renders one input per field instead of a raw textarea.</summary>
[Fact]
public void ObjectParameter_RendersPerFieldInputs_NotRawTextarea()
{
@@ -78,6 +80,7 @@ public class ParameterValueFormTests : BunitContext
Assert.Contains("name", cut.Markup);
}
/// <summary>Verifies that an object parameter round-trips to the canonical nested JSON shape.</summary>
[Fact]
public void ObjectParameter_RoundTripsToCanonicalNestedJsonShape()
{
@@ -101,6 +104,7 @@ public class ParameterValueFormTests : BunitContext
Assert.Equal("widget", order["name"]);
}
/// <summary>Verifies that a list parameter renders typed item rows with add and remove controls.</summary>
[Fact]
public void ListParameter_RendersAddRemoveTypedItemRows()
{
@@ -131,6 +135,7 @@ public class ParameterValueFormTests : BunitContext
Assert.Empty(tagsAfter);
}
/// <summary>Verifies that a parameter containing a reference renders the resolved shape.</summary>
[Fact]
public void RefBearingParameter_RendersResolvedShape()
{
@@ -169,6 +174,7 @@ public class ParameterValueFormTests : BunitContext
Assert.Equal(2, cut.FindAll("input[type=text]").Count);
}
/// <summary>Verifies that a per-field validation error is shown inline.</summary>
[Fact]
public void PerFieldValidationError_SurfacesInline()
{
@@ -185,7 +191,8 @@ public class ParameterValueFormTests : BunitContext
Assert.NotEmpty(cut.FindAll(".text-danger"));
}
// M1 — top-level $ref: empty-state guard must key off resolved fields, not _shapes
// Top-level $ref: empty-state guard must key off resolved fields, not _shapes
/// <summary>Verifies that a top-level reference resolving to an object renders its fields rather than an empty state.</summary>
[Fact]
public void TopLevelRef_ResolvesToObject_RendersFields_NotEmptyState()
{
@@ -228,6 +235,7 @@ public class ParameterValueFormTests : BunitContext
// is cleared on invalid input, not the containing dict). This is the scenario the
// ShiftDict fix is designed for: "rows[1].qty" → "rows[0].qty" when rows[0] is
// removed, so the error/raw-text follows the item rather than being wiped.
/// <summary>Verifies that removing a list item shifts the nested raw text of later items instead of wiping it.</summary>
[Fact]
public void ListItemRemoval_ShiftsNestedRawTextOfLaterItems_NotWiped()
{
@@ -283,6 +291,7 @@ public class ParameterValueFormTests : BunitContext
Assert.NotEmpty(cut.FindAll(".text-danger"));
}
/// <summary>Verifies that the valid-changed callback reports false on a parse error and true once it is fixed.</summary>
[Fact]
public void ValidChanged_ReportsFalseOnAParseError_AndTrueOnceFixed()
{
@@ -298,6 +307,7 @@ public class ParameterValueFormTests : BunitContext
Assert.True(valid.Last());
}
/// <summary>Verifies that the valid-changed callback reports false on first render when a required field is empty.</summary>
[Fact]
public void ValidChanged_ReportsFalseOnFirstRender_WhenARequiredFieldIsEmpty()
{
@@ -315,6 +325,7 @@ public class ParameterValueFormTests : BunitContext
Assert.Empty(cut.FindAll(".text-danger"));
}
/// <summary>Verifies that the valid-changed callback reports true on first render when nothing is required.</summary>
[Fact]
public void ValidChanged_ReportsTrueOnFirstRender_WhenNothingIsRequired()
{
@@ -12,7 +12,7 @@ using SchemaLibraryPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Desig
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
/// <summary>
/// bUnit tests for the Schema Library page (M9-T32c). The page lists the reusable named
/// bUnit tests for the Schema Library page. The page lists the reusable named
/// JSON-Schema library entries, authors create/edit via the shared <c>SchemaBuilder</c>
/// component, and deletes with a confirm — every mutation dispatched through
/// <see cref="ISchemaLibraryService"/> (the guard-running ManagementActor path), never a
@@ -23,6 +23,7 @@ public class SchemaLibraryPageTests : BunitContext
{
private readonly ISchemaLibraryService _service = Substitute.For<ISchemaLibraryService>();
/// <summary>Initializes the test context with a substituted schema library service and an auto-confirming dialog service.</summary>
public SchemaLibraryPageTests()
{
Services.AddSingleton(_service);
@@ -51,6 +52,7 @@ public class SchemaLibraryPageTests : BunitContext
_service.ListAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SharedSchema>>(schemas.ToList()));
/// <summary>Verifies that the page renders the empty state when there are no schemas.</summary>
[Fact]
public void Renders_EmptyState_WhenNoSchemas()
{
@@ -60,6 +62,7 @@ public class SchemaLibraryPageTests : BunitContext
Assert.Contains("No library schemas defined", cut.Markup));
}
/// <summary>Verifies that existing schemas are listed together with their library reference.</summary>
[Fact]
public void Lists_ExistingSchemas_WithLibReference()
{
@@ -78,6 +81,7 @@ public class SchemaLibraryPageTests : BunitContext
});
}
/// <summary>Verifies that choosing New Schema opens the editor with the schema builder.</summary>
[Fact]
public void NewSchema_OpensEditor_WithSchemaBuilder()
{
@@ -92,6 +96,7 @@ public class SchemaLibraryPageTests : BunitContext
Assert.NotNull(cut.FindComponent<SchemaBuilder>());
}
/// <summary>Verifies that creating a schema dispatches a create command carrying the name and schema.</summary>
[Fact]
public void Create_DispatchesCreateCommand_WithNameAndSchema()
{
@@ -112,6 +117,7 @@ public class SchemaLibraryPageTests : BunitContext
"Address", Arg.Any<string?>(), Arg.Any<string>(), Arg.Any<CancellationToken>()));
}
/// <summary>Verifies that a guard error from a create is shown inline and the editor stays open.</summary>
[Fact]
public void Create_GuardError_IsShownInline_AndEditorStaysOpen()
{
@@ -133,6 +139,7 @@ public class SchemaLibraryPageTests : BunitContext
});
}
/// <summary>Verifies that saving with an empty name dispatches no command.</summary>
[Fact]
public void EmptyName_DoesNotDispatch()
{
@@ -148,6 +155,7 @@ public class SchemaLibraryPageTests : BunitContext
Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that deleting a schema confirms first, dispatches a delete command and reloads the list.</summary>
[Fact]
public void Delete_ConfirmsThenDispatchesDeleteCommand_AndReloads()
{
@@ -168,6 +176,7 @@ public class SchemaLibraryPageTests : BunitContext
});
}
/// <summary>Verifies that editing a schema opens the editor and dispatches an update command.</summary>
[Fact]
public void Edit_OpensEditor_AndDispatchesUpdateCommand()
{
@@ -188,10 +197,11 @@ public class SchemaLibraryPageTests : BunitContext
9, "Address", Arg.Any<string?>(), Arg.Any<string>(), Arg.Any<CancellationToken>()));
}
/// <summary>Verifies that row actions are disabled while the editor is open so the row under edit cannot be deleted.</summary>
[Fact]
public void Editing_DisablesRowActions_SoTheRowUnderEditCannotBeDeleted()
{
// #260: while the edit form is open the list still shows the row, but its
// While the edit form is open the list still shows the row, but its
// Edit/Delete actions must be disabled so the row under edit (or a sibling)
// can't be deleted out from under the form.
SeedSchemas(new SharedSchema { Id = 9, Name = "Address", Scope = "us", SchemaJson = "{\"type\":\"object\"}" });
@@ -211,19 +221,28 @@ public class SchemaLibraryPageTests : BunitContext
/// <summary>A dialog service that auto-confirms, so the delete path runs end-to-end.</summary>
private sealed class AlwaysConfirmDialogService : IDialogService
{
/// <inheritdoc />
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.
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null)
=> Task.FromResult(true);
/// <inheritdoc />
public Task<string?> PromptAsync(
string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
/// <summary>Returns the default result without showing a dialog.</summary>
/// <typeparam name="TResult">The type of result the dialog would produce.</typeparam>
/// <param name="title">The dialog title.</param>
/// <param name="body">The dialog body fragment.</param>
/// <param name="size">The optional dialog size.</param>
/// <returns>A task whose result is the default value of <typeparamref name="TResult"/>.</returns>
public Task<TResult?> ShowAsync<TResult>(
string title,
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
@@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
/// <summary>
/// Tests for the schema-library read-accessor (M9-T32c) that T30's schema-driven
/// Tests for the schema-library read-accessor that the schema-driven
/// value-entry forms reuse to resolve <c>{"$ref":"lib:Name"}</c> pointers. It projects
/// <see cref="ISharedSchemaRepository"/> onto a name → schema-JSON map (and a single-name
/// lookup) over a fresh DI scope. The repository is substituted and registered scoped so
@@ -25,6 +25,8 @@ public class SchemaLibraryQueryServiceTests
return (service, repo);
}
/// <summary>Verifies that the schema map associates each library name with its schema JSON.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSchemaMapAsync_MapsNameToSchemaJson()
{
@@ -42,6 +44,8 @@ public class SchemaLibraryQueryServiceTests
Assert.Equal("{\"type\":\"array\"}", map["GeoPoint"]);
}
/// <summary>Verifies that an empty library yields an empty schema map.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSchemaMapAsync_EmptyLibrary_ReturnsEmptyMap()
{
@@ -53,6 +57,8 @@ public class SchemaLibraryQueryServiceTests
Assert.Empty(map);
}
/// <summary>Verifies that resolving a known name returns its schema JSON.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveAsync_KnownName_ReturnsSchemaJson()
{
@@ -65,6 +71,8 @@ public class SchemaLibraryQueryServiceTests
Assert.Equal("{\"type\":\"object\"}", json);
}
/// <summary>Verifies that resolving an unknown name returns null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveAsync_UnknownName_ReturnsNull()
{
@@ -74,6 +82,8 @@ public class SchemaLibraryQueryServiceTests
Assert.Null(await service.ResolveAsync("Missing"));
}
/// <summary>Verifies that resolving an empty name returns null without querying the repository.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveAsync_EmptyName_ReturnsNull_WithoutHittingRepo()
{
@@ -17,7 +17,7 @@ using SharedScriptFormPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.De
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
/// <summary>
/// Regression tests for arch-review P6-12. <c>ApiMethodForm</c> and
/// Regression tests for the sandbox-run cancellation defect. <c>ApiMethodForm</c> and
/// <c>SharedScriptForm</c> run a Test Run sandbox script via
/// <c>AnalysisService.RunInSandboxAsync(request, _runCts.Token)</c> but — unlike
/// <c>TemplateScriptDialog</c>, which cancels/disposes <c>_runCts</c> from an
@@ -36,7 +36,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
/// requires <c>ScriptAnalysisService.RunInSandboxAsync</c> to be <c>virtual</c> —
/// a test-only seam added alongside this fix (mirroring the pre-existing
/// <c>virtual</c> on <c>DebugStreamService.StartStreamAsync</c>/<c>StopStream</c>),
/// not itself part of the P6-12 defect or its fix.
/// not itself part of the defect or its fix.
/// </para>
/// <para>
/// <b>Proof of failure:</b> stashing only the two <c>.razor</c> changes (leaving
@@ -51,6 +51,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
/// </summary>
public class ApiMethodFormSandboxDisposalTests : BunitContext
{
/// <summary>Initializes a new instance of <c>GatedScriptAnalysisService</c>.</summary>
private (IRenderedComponent<ApiMethodFormPage> Cut, GatedScriptAnalysisService Sandbox) RenderPage()
{
// ApiMethodForm mounts a MonacoEditor unconditionally; let bUnit answer its
@@ -99,6 +100,8 @@ public class ApiMethodFormSandboxDisposalTests : BunitContext
typeof(ApiMethodFormPage).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(page);
/// <summary>Verifies that dispose mid run cancels the sandbox run token.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Dispose_MidRun_CancelsTheSandboxRunToken()
{
@@ -124,6 +127,8 @@ public class ApiMethodFormSandboxDisposalTests : BunitContext
await run.WaitAsync(TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that dispose mid run continuation is a no op.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Dispose_MidRun_ContinuationIsANoOp()
{
@@ -198,6 +203,8 @@ public class SharedScriptFormSandboxDisposalTests : BunitContext
typeof(SharedScriptFormPage).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(page);
/// <summary>Verifies that dispose mid run cancels the sandbox run token.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Dispose_MidRun_CancelsTheSandboxRunToken()
{
@@ -220,6 +227,8 @@ public class SharedScriptFormSandboxDisposalTests : BunitContext
await run.WaitAsync(TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that dispose mid run continuation is a no op.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Dispose_MidRun_ContinuationIsANoOp()
{
@@ -260,6 +269,7 @@ internal sealed class GatedScriptAnalysisService : ScriptAnalysisService
private readonly TaskCompletionSource _entered =
new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Initializes a new instance of <c>GatedScriptAnalysisService</c>.</summary>
public GatedScriptAnalysisService()
: base(
Substitute.For<ISharedScriptCatalog>(),
@@ -278,6 +288,7 @@ internal sealed class GatedScriptAnalysisService : ScriptAnalysisService
public void ReleaseRun() => _gate.TrySetResult(
new SandboxRunResult(true, "1", "int", "", null, SandboxErrorKind.None, 1, null));
/// <inheritdoc />
public override async Task<SandboxRunResult> RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct)
{
LastToken = ct;
@@ -30,6 +30,7 @@ public class TemplateAlarmDialogMesReportableTests
private static string TemplateEditMarkup
=> File.ReadAllText(Path.Combine(DesignComponentsRoot, "TemplateEdit.razor"));
/// <summary>Verifies that the alarm dialog has a MES-reportable checkbox.</summary>
[Fact]
public void AlarmDialog_HasMesReportableCheckbox()
{
@@ -39,6 +40,7 @@ public class TemplateAlarmDialogMesReportableTests
Assert.Contains("MES reportable", markup);
}
/// <summary>Verifies that the alarm dialog threads the MES-reportable flag through its parameter and draft.</summary>
[Fact]
public void AlarmDialog_ThreadsMesReportableThroughParameterAndDraft()
{
@@ -48,6 +50,7 @@ public class TemplateAlarmDialogMesReportableTests
Assert.Contains("bool IsMesReportable", markup);
}
/// <summary>Verifies that the alarms tab has a MES column.</summary>
[Fact]
public void AlarmsTab_HasMesColumn()
{
@@ -57,6 +60,7 @@ public class TemplateAlarmDialogMesReportableTests
Assert.Contains("alarm.IsMesReportable", markup);
}
/// <summary>Verifies that the alarms tab passes the MES-reportable flag into and out of the dialog.</summary>
[Fact]
public void AlarmsTab_PassesMesReportableIntoAndOutOfTheDialog()
{
@@ -20,7 +20,7 @@ using TemplateEditPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
/// <summary>
/// bUnit tests for the template editor's READ-ONLY inheritance preview (M9-T26b):
/// bUnit tests for the template editor's READ-ONLY inheritance preview:
/// rendering the FULL transitively-resolved inherited member set (not just the
/// immediate base) annotated with origin, plus a read-only base-changed staleness
/// banner. The resolve query is dispatched through
@@ -41,6 +41,7 @@ public class TemplateEditInheritedSetTests : BunitContext
private const int DerivedId = 30;
private const int BaseId = 20;
/// <summary>Initializes the bUnit context and registers the fakes and services the template editor needs.</summary>
public TemplateEditInheritedSetTests()
{
Services.AddSingleton(_repo);
@@ -121,6 +122,7 @@ public class TemplateEditInheritedSetTests : BunitContext
_resolve.ResolveAsync(DerivedId, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<ResolvedTemplateMembers?>(resolved));
/// <summary>Verifies that transitively inherited members are rendered with an origin annotation.</summary>
[Fact]
public void Renders_TransitivelyInheritedMembers_WithOriginAnnotation()
{
@@ -167,6 +169,7 @@ public class TemplateEditInheritedSetTests : BunitContext
});
}
/// <summary>Verifies that the effective alarm trigger configuration is rendered from the resolved set.</summary>
[Fact]
public void Renders_EffectiveAlarmTriggerConfiguration_FromResolvedSet()
{
@@ -203,6 +206,7 @@ public class TemplateEditInheritedSetTests : BunitContext
});
}
/// <summary>Verifies that the staleness banner is shown when the resolved set is stale.</summary>
[Fact]
public void Shows_StalenessBanner_WhenResolvedSetIsStale()
{
@@ -235,6 +239,7 @@ public class TemplateEditInheritedSetTests : BunitContext
});
}
/// <summary>Verifies that no staleness banner is shown when the resolved set is not stale.</summary>
[Fact]
public void NoStalenessBanner_WhenResolvedSetIsNotStale()
{
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
/// <summary>
/// Task 24: the template editor exposes a Native Alarm Sources subsection (a new
/// The template editor exposes a Native Alarm Sources subsection (a new
/// tab) for authoring read-only native alarm bindings. <c>TemplateEdit</c> is a
/// heavyweight page (~10 injected services with their own graphs); like the other
/// TemplateEdit coverage in this suite (see <c>TestRunWarningTests</c>), these are
@@ -28,13 +28,14 @@ public class TemplateNativeAlarmSourceEditorTests
private static string TemplateEditMarkup
=> File.ReadAllText(Path.Combine(DesignComponentsRoot, "TemplateEdit.razor"));
// M10 residual: the authoring form moved out of the page into the dialog body
// The authoring form moved out of the page into the dialog body
// component hosted by IDialogService.ShowAsync. The field-level assertions
// below follow it there; the tab, the connection filtering, and the CRUD
// wiring all still live on the page.
private static string NativeSourceDialogMarkup
=> File.ReadAllText(Path.Combine(DesignComponentsRoot, "TemplateNativeAlarmSourceDialog.razor"));
/// <summary>Verifies that the template editor has a Native Alarms tab.</summary>
[Fact]
public void TemplateEditor_HasNativeAlarmsTab()
{
@@ -45,6 +46,7 @@ public class TemplateNativeAlarmSourceEditorTests
Assert.Contains("Native Alarm Sources", markup);
}
/// <summary>Verifies that the native alarms form has connection, source filter and lock fields.</summary>
[Fact]
public void NativeAlarmsForm_HasConnectionSourceFilterAndLockFields()
{
@@ -67,10 +69,11 @@ public class TemplateNativeAlarmSourceEditorTests
Assert.Contains("@bind=\"_nasIsLocked\"", dialogMarkup);
}
/// <summary>Verifies that the native alarms form is hosted by the dialog service rather than embedded in the page.</summary>
[Fact]
public void NativeAlarmsForm_IsHostedByTheDialogService()
{
// M10 residual: the page-embedded modal is gone — the form opens through
// The page-embedded modal is gone — the form opens through
// IDialogService.ShowAsync so the host owns the backdrop, focus trap,
// Escape, and focus restoration.
var pageMarkup = TemplateEditMarkup;
@@ -83,6 +86,7 @@ public class TemplateNativeAlarmSourceEditorTests
Assert.Contains("DialogContext<bool> Context", dialogMarkup);
}
/// <summary>Verifies that native alarm add, update and delete are wired to the repository followed by a save.</summary>
[Fact]
public void NativeAlarmsCrud_WiresRepositoryAddUpdateDeleteWithSave()
{
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
/// <summary>
/// Regression tests for CentralUI-014. Test Run wires <c>External</c>,
/// Regression tests for the Test Run side-effect warning. Test Run wires <c>External</c>,
/// <c>Database</c>, and <c>Notify</c> to central's real services, so a Test Run
/// has production-equivalent side effects. The finding asked, at minimum, that
/// this blast radius be surfaced to the user. The Test Run panels in
@@ -29,9 +29,11 @@ public class TestRunWarningTests
private static string Read(string fileName)
=> File.ReadAllText(Path.Combine(SrcRoot, fileName));
// M10 residual: the template Test Run panel moved out of TemplateEdit.razor
// The template Test Run panel moved out of TemplateEdit.razor
// into TemplateScriptDialog.razor when the page-embedded script modal was
// migrated to the IDialogService host. The warning travelled with it.
/// <summary>Verifies that a Test Run panel with a real I/O surface shows the real I/O badge and warning.</summary>
/// <param name="razorFile">The Razor file containing the Test Run panel.</param>
[Theory]
[InlineData("SharedScriptForm.razor")]
[InlineData("TemplateScriptDialog.razor")]
@@ -47,6 +49,7 @@ public class TestRunWarningTests
Assert.Contains("Side effects are permanent", markup);
}
/// <summary>Verifies that the API method form Test Run shows no real I/O badge because the inbound API has no side-effect surface.</summary>
[Fact]
public void ApiMethodForm_TestRun_HasNoRealIoBadge_BecauseInboundApiHasNoSideEffectSurface()
{
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Forms;
public class OpcUaEndpointEditorTests : BunitContext
{
/// <summary>Initializes the bUnit context with no-op endpoint verification and certificate management services.</summary>
public OpcUaEndpointEditorTests()
{
// OpcUaEndpointEditor injects IEndpointVerificationService for its Verify-endpoint
@@ -21,6 +22,7 @@ public class OpcUaEndpointEditorTests : BunitContext
Services.AddSingleton<ICertManagementService>(Substitute.For<ICertManagementService>());
}
/// <summary>Verifies that the editor renders all four section labels.</summary>
[Fact]
public void Renders_All_Four_Section_Labels()
{
@@ -35,6 +37,7 @@ public class OpcUaEndpointEditorTests : BunitContext
Assert.Contains("Heartbeat", cut.Markup);
}
/// <summary>Verifies that editing a field mutates the configuration instance passed to the editor.</summary>
[Fact]
public void Binding_MutatesPassedConfigInstance()
{
@@ -46,6 +49,7 @@ public class OpcUaEndpointEditorTests : BunitContext
Assert.Equal("opc.tcp://new-host:4840", config.EndpointUrl);
}
/// <summary>Verifies that enabling the heartbeat creates the heartbeat sub-object.</summary>
[Fact]
public void EnableHeartbeat_CreatesSubObject()
{
@@ -58,6 +62,7 @@ public class OpcUaEndpointEditorTests : BunitContext
Assert.NotNull(config.Heartbeat);
}
/// <summary>Verifies that removing the heartbeat sets the heartbeat sub-object to null.</summary>
[Fact]
public void RemoveHeartbeat_NullsSubObject()
{
@@ -72,6 +77,7 @@ public class OpcUaEndpointEditorTests : BunitContext
Assert.Null(config.Heartbeat);
}
/// <summary>Verifies that the errors parameter renders red error text beside the affected fields.</summary>
[Fact]
public void Errors_Parameter_RendersPerFieldRedText()
{
@@ -85,6 +91,7 @@ public class OpcUaEndpointEditorTests : BunitContext
Assert.Contains("text-danger", cut.Markup);
}
/// <summary>Verifies that a legacy configuration renders the warning banner.</summary>
[Fact]
public void IsLegacy_True_RendersWarningBanner()
{
@@ -98,6 +105,7 @@ public class OpcUaEndpointEditorTests : BunitContext
// ── Layer E: new editor sections ──
/// <summary>Verifies that the editor renders the authentication section label.</summary>
[Fact]
public void Renders_Authentication_Section_Label()
{
@@ -105,6 +113,7 @@ public class OpcUaEndpointEditorTests : BunitContext
Assert.Contains("Authentication", cut.Markup);
}
/// <summary>Verifies that enabling authentication creates the user identity sub-object.</summary>
[Fact]
public void EnableAuthentication_CreatesUserIdentitySubObject()
{
@@ -118,6 +127,7 @@ public class OpcUaEndpointEditorTests : BunitContext
Assert.Equal(OpcUaUserTokenType.Anonymous, config.UserIdentity!.TokenType);
}
/// <summary>Verifies that removing authentication sets the user identity to null.</summary>
[Fact]
public void RemoveAuthentication_NullsUserIdentity()
{
@@ -137,6 +147,7 @@ public class OpcUaEndpointEditorTests : BunitContext
Assert.Null(config.UserIdentity);
}
/// <summary>Verifies that the username/password token type renders username and password inputs.</summary>
[Fact]
public void UsernamePassword_RendersUsernameAndPasswordInputs()
{
@@ -155,6 +166,7 @@ public class OpcUaEndpointEditorTests : BunitContext
Assert.DoesNotContain(">Certificate path<", cut.Markup);
}
/// <summary>Verifies that the X.509 certificate token type renders the certificate fields.</summary>
[Fact]
public void X509Certificate_RendersCertificateFields()
{
@@ -172,6 +184,7 @@ public class OpcUaEndpointEditorTests : BunitContext
Assert.DoesNotContain(">Username<", cut.Markup);
}
/// <summary>Verifies that the anonymous token type shows no extra credential fields.</summary>
[Fact]
public void AnonymousTokenType_ShowsNoExtraFields()
{
@@ -185,6 +198,7 @@ public class OpcUaEndpointEditorTests : BunitContext
Assert.DoesNotContain(">Certificate path<", cut.Markup);
}
/// <summary>Verifies that enabling the deadband creates the deadband sub-object.</summary>
[Fact]
public void EnableDeadband_CreatesDeadbandSubObject()
{
@@ -197,6 +211,7 @@ public class OpcUaEndpointEditorTests : BunitContext
Assert.NotNull(config.Deadband);
}
/// <summary>Verifies that removing the deadband sets the deadband sub-object to null.</summary>
[Fact]
public void RemoveDeadband_NullsDeadband()
{
@@ -211,6 +226,7 @@ public class OpcUaEndpointEditorTests : BunitContext
Assert.Null(config.Deadband);
}
/// <summary>Verifies that the advanced subscription section renders.</summary>
[Fact]
public void AdvancedSubscription_Section_Renders()
{
@@ -223,6 +239,7 @@ public class OpcUaEndpointEditorTests : BunitContext
Assert.Contains("Timestamps to return", cut.Markup);
}
/// <summary>Verifies that a user identity error renders under the username field.</summary>
[Fact]
public void UserIdentityError_RendersPerFieldUnderUsername()
{
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Layout;
/// <summary>
/// bUnit rendering tests for the rail <see cref="NavMenu"/>. After the
/// ZB.MOM.WW.Theme adoption (Task 2.4) the nav is composed of the kit's
/// ZB.MOM.WW.Theme adoption the nav is composed of the kit's
/// <c>NavRailSection</c> (a native <c>&lt;details&gt;</c> disclosure, expanded
/// by default — no JS interop, works in static SSR) and <c>NavRailItem</c>
/// links. These tests verify the structure (sections render as
@@ -74,6 +74,7 @@ public class NavMenuTests : BunitContext
return host.FindComponent<NavMenu>();
}
/// <summary>Verifies that a Viewer sees the Flow Debugger link and no empty Notifications section.</summary>
[Fact]
public void Viewer_HasFlowDebuggerAndNoEmptyNotificationsSection()
{
@@ -85,6 +86,7 @@ public class NavMenuTests : BunitContext
});
}
/// <summary>Verifies that nav sections render as kit details disclosures that are expanded by default.</summary>
[Fact]
public void Sections_RenderAsKitDetailsDisclosures_ExpandedByDefault()
{
@@ -108,6 +110,7 @@ public class NavMenuTests : BunitContext
});
}
/// <summary>Verifies that the Deployment section renders all of its items for a Deployer.</summary>
[Fact]
public void DeploymentSection_RendersAllItems_ForDeployer()
{
@@ -121,6 +124,7 @@ public class NavMenuTests : BunitContext
});
}
/// <summary>Verifies that the Notifications section shows all of its items for a user with multiple roles.</summary>
[Fact]
public void NotificationsSection_ShowsAllItems_ForMultiRoleUser()
{
@@ -136,6 +140,7 @@ public class NavMenuTests : BunitContext
});
}
/// <summary>Verifies that an Administrator-only user sees only the SMTP item in the Notifications section.</summary>
[Fact]
public void NotificationsSection_AdminOnlyUser_SeesOnlySmtp()
{
@@ -150,6 +155,7 @@ public class NavMenuTests : BunitContext
});
}
/// <summary>Verifies that the old routes are no longer linked from the nav.</summary>
[Fact]
public void OldRoutes_AreNoLongerLinked()
{
@@ -162,6 +168,7 @@ public class NavMenuTests : BunitContext
});
}
/// <summary>Verifies that the Secrets link in the Admin section is visible to an Administrator.</summary>
[Fact]
public void AdminSection_SecretsLink_VisibleForAdministrator()
{
@@ -174,6 +181,7 @@ public class NavMenuTests : BunitContext
});
}
/// <summary>Verifies that the Secrets link in the Admin section is absent for a non-Administrator.</summary>
[Fact]
public void AdminSection_SecretsLink_AbsentForNonAdministrator()
{
@@ -193,6 +201,7 @@ public class NavMenuTests : BunitContext
// is the witness — its heading lives inside the policy block, so a denied
// user must not even see the eyebrow.
/// <summary>Verifies that a policy-gated section is visible to an authorized principal.</summary>
[Fact]
public void PolicyGatedSection_IsVisible_ForAuthorizedPrincipal()
{
@@ -207,6 +216,7 @@ public class NavMenuTests : BunitContext
});
}
/// <summary>Verifies that a policy-gated section is absent for an unauthorized principal.</summary>
[Fact]
public void PolicyGatedSection_IsAbsent_ForUnauthorizedPrincipal()
{
@@ -221,6 +231,7 @@ public class NavMenuTests : BunitContext
});
}
/// <summary>Verifies that all policy-gated sections are absent for an anonymous principal.</summary>
[Fact]
public void PolicyGatedSections_AllAbsent_ForAnonymousPrincipal()
{
@@ -86,10 +86,14 @@ public class PendingUpdatesIndicatorTests : BunitContext
{
private readonly ClaimsPrincipal _user;
/// <summary>Initializes a new instance of the <c>CountingAuthStateProvider</c> class.</summary>
/// <param name="user">The user whose claims the provider exposes.</param>
public CountingAuthStateProvider(ClaimsPrincipal user) => _user = user;
/// <summary>Gets the calls recorded by this fake.</summary>
public int Calls { get; private set; }
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
{
Calls++;
@@ -122,6 +126,7 @@ public class PendingUpdatesIndicatorTests : BunitContext
return host.FindComponent<PendingUpdatesIndicator>();
}
/// <summary>Verifies that a user with only the Viewer role sees no pending-updates indicator.</summary>
[Fact]
public void Viewer_RendersNothing()
{
@@ -130,6 +135,7 @@ public class PendingUpdatesIndicatorTests : BunitContext
Assert.True(string.IsNullOrWhiteSpace(cut.Markup));
}
/// <summary>Verifies that a Deployer with nothing pending sees the up-to-date state.</summary>
[Fact]
public void Deployer_WithNothingPending_ShowsUpToDate()
{
@@ -145,6 +151,7 @@ public class PendingUpdatesIndicatorTests : BunitContext
});
}
/// <summary>Verifies that the snapshot is captioned with the instant at which it was computed.</summary>
[Fact]
public void Snapshot_IsCaptionedWithItsComputedInstant()
{
@@ -160,6 +167,7 @@ public class PendingUpdatesIndicatorTests : BunitContext
});
}
/// <summary>Verifies that a user who is both Deployer and Administrator sees the total pending count and both links.</summary>
[Fact]
public void DeployerAndAdministrator_WithPendingWork_ShowsTotalAndBothLinks()
{
@@ -188,6 +196,7 @@ public class PendingUpdatesIndicatorTests : BunitContext
});
}
/// <summary>Verifies that a Deployer without the Administrator role sees the site count as plain text.</summary>
[Fact]
public void DeployerWithoutAdministrator_ShowsSiteCountAsPlainText()
{
@@ -210,6 +219,7 @@ public class PendingUpdatesIndicatorTests : BunitContext
});
}
/// <summary>Verifies that an Administrator without the Deployer role sees the instance count as plain text.</summary>
[Fact]
public void AdministratorWithoutDeployer_ShowsInstanceCountAsPlainText()
{
@@ -227,6 +237,7 @@ public class PendingUpdatesIndicatorTests : BunitContext
});
}
/// <summary>Verifies that a cache fault shows an unavailable state with the fault message as a tooltip.</summary>
[Fact]
public void CacheFault_ShowsUnavailableWithTheMessageAsTooltip()
{
@@ -241,6 +252,7 @@ public class PendingUpdatesIndicatorTests : BunitContext
});
}
/// <summary>Verifies that a site-scoped Deployer is shown counts for the permitted sites only.</summary>
[Fact]
public void SiteScopedDeployer_CountsOnlyPermittedSites()
{
@@ -264,6 +276,7 @@ public class PendingUpdatesIndicatorTests : BunitContext
});
}
/// <summary>Verifies that disposing the component after it has rendered does not throw.</summary>
[Fact]
public void Dispose_AfterRender_DoesNotThrow()
{
@@ -278,6 +291,7 @@ public class PendingUpdatesIndicatorTests : BunitContext
instance.Dispose();
}
/// <summary>Verifies that a cascaded authentication state is used instead of asking the authentication state provider.</summary>
[Fact]
public void CascadedAuthenticationState_IsUsedInsteadOfAskingTheProvider()
{
@@ -296,6 +310,8 @@ public class PendingUpdatesIndicatorTests : BunitContext
Assert.Equal(0, anonymous.Calls);
}
/// <summary>Verifies that polling does not resolve any further authentication state.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Polling_ResolvesNoFurtherAuthenticationState()
{
@@ -331,6 +347,7 @@ public class PendingUpdatesIndicatorTests : BunitContext
Assert.Contains("2 instances", cut.Markup);
}
/// <summary>Verifies that an authentication state change hides the indicator when the role is revoked.</summary>
[Fact]
public void AuthenticationStateChange_HidesTheBlockWhenTheRoleIsRevoked()
{
@@ -358,11 +375,16 @@ public class PendingUpdatesIndicatorTests : BunitContext
{
private ClaimsPrincipal _user;
/// <summary>Initializes a new instance of the <c>MutableAuthStateProvider</c> class.</summary>
/// <param name="user">The user whose claims the provider exposes.</param>
public MutableAuthStateProvider(ClaimsPrincipal user) => _user = user;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(new AuthenticationState(_user));
/// <summary>Replaces the user returned by the provider and notifies listeners of the authentication state change.</summary>
/// <param name="user">The user whose claims the provider exposes.</param>
public void SetUser(ClaimsPrincipal user)
{
_user = user;
@@ -370,6 +392,7 @@ public class PendingUpdatesIndicatorTests : BunitContext
}
}
/// <summary>Verifies that polling starts once an asynchronous authentication resolve completes.</summary>
[Fact]
public void Polling_StartsAfterAnAsynchronousAuthResolveCompletes()
{
@@ -408,8 +431,11 @@ public class PendingUpdatesIndicatorTests : BunitContext
{
private readonly Task<AuthenticationState> _state;
/// <summary>Initializes a new instance of the <c>DeferredAuthStateProvider</c> class.</summary>
/// <param name="state">The authentication state the provider returns.</param>
public DeferredAuthStateProvider(Task<AuthenticationState> state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync() => _state;
}
}
@@ -29,6 +29,7 @@ public class AlarmSummaryRenderTests : BunitContext
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly FakeSiteAlarmLiveCache _liveCache = new();
/// <summary>Initializes the test class and configures the faked services and the bUnit context.</summary>
public AlarmSummaryRenderTests()
{
// Two rows whose default sort (severity DESC) orders Zeta (900) before
@@ -41,7 +42,7 @@ public class AlarmSummaryRenderTests : BunitContext
_summary.GetSiteAlarmsAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new AlarmSummaryResult(rows, Array.Empty<string>())));
// BuildFromLiveAlarms (Task 5): flatten the pushed live snapshot the same way
// BuildFromLiveAlarms: flatten the pushed live snapshot the same way
// the real service does, so the live-path render tests exercise real behavior.
_summary.BuildFromLiveAlarms(Arg.Any<IReadOnlyList<AlarmStateChanged>>())
.Returns(ci =>
@@ -90,6 +91,7 @@ public class AlarmSummaryRenderTests : BunitContext
return cut;
}
/// <summary>Verifies that sortable headers carry a tabindex and an aria-sort attribute.</summary>
[Fact]
public void SortableHeaders_CarryTabindexAndAriaSort()
{
@@ -113,6 +115,7 @@ public class AlarmSummaryRenderTests : BunitContext
Assert.Null(stateKind.GetAttribute("aria-sort"));
}
/// <summary>Verifies that pressing Enter on the instance header flips the row order.</summary>
[Fact]
public void EnterOnInstanceHeader_FlipsRowOrder()
{
@@ -133,8 +136,9 @@ public class AlarmSummaryRenderTests : BunitContext
cut.FindAll("tr[data-test='alarm-summary-row']")[0]
.QuerySelector("td")!.TextContent.Trim();
// ── plan #10 Task 5: live-cache-first with poll fallback ───────────────────
// ── live-cache-first with poll fallback ───────────────────
/// <summary>Verifies that selecting a site subscribes to the live cache.</summary>
[Fact]
public void SelectingSite_SubscribesToTheLiveCache()
{
@@ -144,6 +148,7 @@ public class AlarmSummaryRenderTests : BunitContext
Assert.Equal(1, _liveCache.LastSubscribedSite);
}
/// <summary>Verifies that the poll fallback renders when the live cache is not live yet.</summary>
[Fact]
public void PollFallbackRenders_WhenCacheNotLiveYet()
{
@@ -157,6 +162,7 @@ public class AlarmSummaryRenderTests : BunitContext
Assert.Equal("Zeta", FirstRowInstance(cut)); // severity-desc default
}
/// <summary>Verifies that a change notification rebuilds the rendered rows from the live snapshot.</summary>
[Fact]
public void OnChangedDelta_RebuildsRenderedRowsFromLiveSnapshot()
{
@@ -176,6 +182,7 @@ public class AlarmSummaryRenderTests : BunitContext
});
}
/// <summary>Verifies that leaving a site unsubscribes from the live cache.</summary>
[Fact]
public void LeavingSite_UnsubscribesFromTheLiveCache()
{
@@ -188,6 +195,7 @@ public class AlarmSummaryRenderTests : BunitContext
Assert.Equal(1, _liveCache.DisposeCount);
}
/// <summary>Verifies that changing the site resubscribes to the new site.</summary>
[Fact]
public void ChangingSite_ResubscribesToTheNewSite()
{
@@ -200,6 +208,7 @@ public class AlarmSummaryRenderTests : BunitContext
Assert.Equal(2, _liveCache.LastSubscribedSite);
}
/// <summary>Verifies that disposing the component unsubscribes from the live cache.</summary>
[Fact]
public void DisposingComponent_UnsubscribesFromTheLiveCache()
{
@@ -212,6 +221,7 @@ public class AlarmSummaryRenderTests : BunitContext
// ── arch-review R2 N4: stale-site poll guard ───────────────────────────────
/// <summary>Verifies that an in-flight poll for the previous site does not overwrite the new site's rows.</summary>
[Fact]
public void InFlightPollForPreviousSite_DoesNotOverwriteNewSitesRows()
{
@@ -231,7 +241,7 @@ public class AlarmSummaryRenderTests : BunitContext
cut.Find("[data-test='alarm-summary-site']").Change("2"); // site 2 renders
cut.WaitForAssertion(() => Assert.Equal("Site2Instance", FirstRowInstance(cut)));
// The stale site-1 fan-out now completes — it must be dropped, not rendered.
// The stale first-site fan-out now completes — it must be dropped, not rendered.
site1Tcs.SetResult(new AlarmSummaryResult(
new List<AlarmSummaryRow>
{
@@ -248,6 +258,7 @@ public class AlarmSummaryRenderTests : BunitContext
// ── arch-review R2 N5: while live, the poll updates only _notReporting ──────
/// <summary>Verifies that a poll while live updates only the not-reporting list and does not regress the live rows.</summary>
[Fact]
public void PollWhileLive_UpdatesNotReportingOnly_DoesNotRegressLiveRows()
{
@@ -280,9 +291,10 @@ public class AlarmSummaryRenderTests : BunitContext
});
}
// ── arch-review P6-2: site scoping (a scoped Deployer must not watch another
// ── site scoping (a scoped Deployer must not watch another
// site's live alarms — the page was missed by the ad0100f2 scoping survey) ──
/// <summary>Verifies that a site-scoped deployment user sees only the permitted site in the picker.</summary>
[Fact]
public void SiteScoping_ScopedDeploymentUser_SeesOnlyPermittedSiteInPicker()
{
@@ -302,6 +314,7 @@ public class AlarmSummaryRenderTests : BunitContext
Assert.DoesNotContain("Site 2", cut.Markup);
}
/// <summary>Verifies that a forged out-of-scope site id is denied and never fetched.</summary>
[Fact]
public void SiteScoping_ForgedOutOfScopeSiteId_IsDeniedNotFetched()
{
@@ -329,6 +342,7 @@ public class AlarmSummaryRenderTests : BunitContext
// ── arch-review R2 N7: disposal guard on the live callback ─────────────────
/// <summary>Verifies that a live callback racing with disposal is dropped without a rebuild or an exception.</summary>
[Fact]
public void LiveCallbackRacingDispose_IsDropped_NoRebuildNoThrow()
{
@@ -363,11 +377,16 @@ public class AlarmSummaryRenderTests : BunitContext
private IReadOnlyList<AlarmStateChanged> _current = Array.Empty<AlarmStateChanged>();
private bool _live;
/// <summary>Gets the number of subscribe calls received.</summary>
public int SubscribeCount { get; private set; }
/// <summary>Gets the number of subscriptions disposed.</summary>
public int DisposeCount { get; private set; }
/// <summary>Gets the site id of the most recent subscribe call.</summary>
public int? LastSubscribedSite { get; private set; }
/// <summary>Gets the change callback captured by the most recent subscribe call.</summary>
public Action? CapturedCallback => _onChanged;
/// <inheritdoc />
public IDisposable Subscribe(int siteId, Action onChanged)
{
SubscribeCount++;
@@ -376,8 +395,10 @@ public class AlarmSummaryRenderTests : BunitContext
return new Subscription(this);
}
/// <inheritdoc />
public IReadOnlyList<AlarmStateChanged> GetCurrentAlarms(int siteId) => _current;
/// <inheritdoc />
public bool IsLive(int siteId) => _live;
/// <summary>
@@ -386,8 +407,11 @@ public class AlarmSummaryRenderTests : BunitContext
/// </summary>
public IReadOnlyList<string> NotReporting { get; set; } = Array.Empty<string>();
/// <inheritdoc />
public IReadOnlyList<string> GetNotReportingInstances(int siteId) => NotReporting;
/// <summary>Replaces the current snapshot, marks the cache live and raises the change callback.</summary>
/// <param name="alarms">The alarm snapshot to publish.</param>
public void PushAlarms(IReadOnlyList<AlarmStateChanged> alarms)
{
_current = alarms;
@@ -400,8 +424,11 @@ public class AlarmSummaryRenderTests : BunitContext
private readonly FakeSiteAlarmLiveCache _owner;
private bool _disposed;
/// <summary>Initializes the subscription for the given fake cache.</summary>
/// <param name="owner">The fake cache that issued the subscription.</param>
public Subscription(FakeSiteAlarmLiveCache owner) => _owner = owner;
/// <summary>Releases the resources held by this instance.</summary>
public void Dispose()
{
if (_disposed) return;
@@ -15,7 +15,7 @@ using AlarmSummaryPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monito
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring;
/// <summary>
/// Covers the Alarm Summary flat table's two rendering paths (arch-review WP2.4). A short
/// Covers the Alarm Summary flat table's two rendering paths. A short
/// list renders every row with a plain foreach; past the threshold the same row template is
/// handed to <c>Virtualize</c> so a site with thousands of alarms stops materialising the
/// whole table into the circuit's render tree on every delta. Row markup — including the
@@ -69,6 +69,7 @@ public class AlarmSummaryVirtualizeTests : BunitContext
return cut;
}
/// <summary>Verifies that a short alarm list renders every row inline.</summary>
[Fact]
public void ShortList_RendersEveryRowInline()
{
@@ -82,6 +83,7 @@ public class AlarmSummaryVirtualizeTests : BunitContext
Assert.Contains("Showing 20 of 20", cut.Markup);
}
/// <summary>Verifies that a long alarm list is virtualized and does not render every row.</summary>
[Fact]
public void LongList_VirtualizesWithoutRenderingEveryRow()
{
@@ -100,17 +102,22 @@ public class AlarmSummaryVirtualizeTests : BunitContext
/// <summary>Never goes live, so the page keeps its poll snapshot for these tests.</summary>
private sealed class InertLiveCache : ISiteAlarmLiveCache
{
/// <inheritdoc />
public IDisposable Subscribe(int siteId, Action onChanged) => new NoOp();
/// <inheritdoc />
public IReadOnlyList<AlarmStateChanged> GetCurrentAlarms(int siteId) =>
Array.Empty<AlarmStateChanged>();
/// <inheritdoc />
public bool IsLive(int siteId) => false;
/// <inheritdoc />
public IReadOnlyList<string> GetNotReportingInstances(int siteId) => Array.Empty<string>();
private sealed class NoOp : IDisposable
{
/// <summary>Does nothing; the no-op subscription holds no resources.</summary>
public void Dispose() { }
}
}
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Components;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring;
/// <summary>
/// Regression tests for CentralUI-008. <c>&lt;input type="datetime-local"&gt;</c>
/// Regression tests for the datetime-local conversion. <c>&lt;input type="datetime-local"&gt;</c>
/// yields the value the user typed in their <i>browser-local</i> time zone. The
/// audit-log filter converted it with <c>new DateTimeOffset(value, TimeSpan.Zero)</c>
/// — relabelling the local wall-clock value as UTC, shifting the query window by
@@ -12,12 +12,14 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring;
/// </summary>
public class BrowserTimeTests
{
/// <summary>Verifies that local input to utc, when null, returns null.</summary>
[Fact]
public void LocalInputToUtc_Null_ReturnsNull()
{
Assert.Null(BrowserTime.LocalInputToUtc(null, 0));
}
/// <summary>Verifies that local input to utc, when utc browser, leaves time unchanged.</summary>
[Fact]
public void LocalInputToUtc_UtcBrowser_LeavesTimeUnchanged()
{
@@ -29,6 +31,7 @@ public class BrowserTimeTests
Assert.Equal(new DateTimeOffset(2026, 5, 16, 9, 30, 0, TimeSpan.Zero), utc);
}
/// <summary>Verifies that local input to utc, when positive utc offset browser, subtracts offset.</summary>
[Fact]
public void LocalInputToUtc_PositiveUtcOffsetBrowser_SubtractsOffset()
{
@@ -41,10 +44,11 @@ public class BrowserTimeTests
Assert.Equal(new DateTimeOffset(2026, 5, 16, 7, 30, 0, TimeSpan.Zero), utc);
}
/// <summary>Verifies that local input to utc, when negative utc offset browser, adds offset.</summary>
[Fact]
public void LocalInputToUtc_NegativeUtcOffsetBrowser_AddsOffset()
{
// A browser at UTC-5 (US Eastern, standard time) reports getTimezoneOffset() == 300.
// A browser at UTC minus 5 hours (US Eastern, standard time) reports getTimezoneOffset() == 300.
// The user typing 09:30 local means 14:30 UTC.
var local = new DateTime(2026, 5, 16, 9, 30, 0);
@@ -53,6 +57,7 @@ public class BrowserTimeTests
Assert.Equal(new DateTimeOffset(2026, 5, 16, 14, 30, 0, TimeSpan.Zero), utc);
}
/// <summary>Verifies that local input to utc, when non utc browser, does not equal naive relabelling.</summary>
[Fact]
public void LocalInputToUtc_NonUtcBrowser_DoesNotEqualNaiveRelabelling()
{
@@ -30,6 +30,7 @@ public class FailoverConfirmLabelTests : BunitContext
private readonly IManualFailoverService _failover = Substitute.For<IManualFailoverService>();
private readonly DialogService _dialog = new();
/// <summary>Initializes the bUnit context with an authenticated administrator and substitute failover service.</summary>
public FailoverConfirmLabelTests()
{
var claims = new[]
@@ -97,6 +98,8 @@ public class FailoverConfirmLabelTests : BunitContext
return host.FindAll("div.modal-footer button").ToList();
}
/// <summary>Verifies that the failover confirm button reads "Fail over" rather than "Delete".</summary>
/// <param name="siteId">The site id to fail over, or <see langword="null"/> for the central cluster.</param>
[Theory]
[InlineData(null)] // central pair
[InlineData("SiteA")] // site pair
@@ -111,6 +114,7 @@ public class FailoverConfirmLabelTests : BunitContext
Assert.Contains("Cancel", footerButtons);
}
/// <summary>Verifies that the failover confirm button keeps its destructive styling.</summary>
[Fact]
public void Failover_confirm_button_keeps_destructive_styling()
{
@@ -122,12 +126,14 @@ public class FailoverConfirmLabelTests : BunitContext
Assert.Contains("btn-danger", confirm.ClassList);
}
/// <summary>Verifies that a danger confirmation without a custom label still falls back to "Delete".</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task An_unlabelled_danger_confirm_still_falls_back_to_delete()
{
// The fallback is retained deliberately: ~20 genuine delete call sites rely
// on it (and the Playwright suites click the literal "Delete"). This pins that
// the row-28 fix is additive and did not change the default.
// the fail-over label fix is additive and did not change the default.
var cut = Render<DialogHost>();
await cut.InvokeAsync(() => { _ = _dialog.ConfirmAsync("Delete thing", "Sure?", danger: true); });
@@ -61,6 +61,7 @@ public class HealthFailoverButtonTests : BunitContext
return host.FindComponent<CentralFailoverControl>();
}
/// <summary>Verifies that the failover button is hidden for a non-admin user.</summary>
[Fact]
public void Button_is_hidden_for_a_non_admin()
{
@@ -71,6 +72,7 @@ public class HealthFailoverButtonTests : BunitContext
Assert.Empty(cut.FindAll("button"));
}
/// <summary>Verifies that the failover button is rendered and enabled for an admin when a standby node exists.</summary>
[Fact]
public void Button_is_rendered_and_enabled_for_an_admin_with_a_standby()
{
@@ -83,6 +85,7 @@ public class HealthFailoverButtonTests : BunitContext
Assert.Contains("failover", button.TextContent, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that the failover button is disabled with an explanatory title when there is no standby.</summary>
[Fact]
public void Button_is_disabled_with_an_explanatory_title_when_there_is_no_standby()
{
@@ -96,6 +99,8 @@ public class HealthFailoverButtonTests : BunitContext
Assert.Contains("outage", button.GetAttribute("title"), StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that confirming the dialog triggers the failover exactly once.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Confirming_triggers_the_failover_exactly_once()
{
@@ -110,6 +115,8 @@ public class HealthFailoverButtonTests : BunitContext
await _failover.Received(1).FailOverCentralAsync("tester");
}
/// <summary>Verifies that cancelling the confirmation does not trigger a failover.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Cancelling_the_confirmation_does_not_trigger_a_failover()
{
@@ -122,6 +129,8 @@ public class HealthFailoverButtonTests : BunitContext
await _failover.DidNotReceive().FailOverCentralAsync(Arg.Any<string>());
}
/// <summary>Verifies that the confirmation warns that this page will disconnect.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Confirmation_warns_that_this_page_will_disconnect()
{
@@ -141,6 +150,8 @@ public class HealthFailoverButtonTests : BunitContext
Arg.Any<string?>());
}
/// <summary>Verifies that a refused failover surfaces the refusal instead of claiming success.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task A_refused_failover_surfaces_the_refusal_instead_of_claiming_success()
{
@@ -156,7 +167,7 @@ public class HealthFailoverButtonTests : BunitContext
Assert.Contains("no standby", cut.Markup, StringComparison.OrdinalIgnoreCase);
}
// ---- Site-pair failover (Task 10) ------------------------------------------
// ---- Site-pair failover ------------------------------------------
// Same control, SiteId set. The differences that matter: it calls the site path,
// and its confirmation must NOT claim the admin's own page will drop (it won't —
// the site is a different cluster).
@@ -175,6 +186,8 @@ public class HealthFailoverButtonTests : BunitContext
return host.FindComponent<CentralFailoverControl>();
}
/// <summary>Verifies that confirming on the site card triggers the site failover path.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Site_card_confirming_triggers_the_site_failover_path()
{
@@ -190,6 +203,8 @@ public class HealthFailoverButtonTests : BunitContext
await _failover.DidNotReceive().FailOverCentralAsync(Arg.Any<string>());
}
/// <summary>Verifies that the site card confirmation does not claim that this page will disconnect.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Site_card_confirmation_does_not_claim_this_page_disconnects()
{
@@ -210,6 +225,8 @@ public class HealthFailoverButtonTests : BunitContext
Arg.Any<string?>());
}
/// <summary>Verifies that the site card surfaces a refusal from the site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Site_card_surfaces_a_refusal_from_the_site()
{
@@ -225,6 +242,8 @@ public class HealthFailoverButtonTests : BunitContext
Assert.Contains("No standby available", cut.Markup);
}
/// <summary>Verifies that the site card reports an unreachable site distinctly from a refusal.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Site_card_surfaces_an_unreachable_site_distinctly_from_a_refusal()
{
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring;
/// <summary>
/// Regression tests for CentralUI-007. The design doc classifies the Site Event
/// Regression tests for page authorization. The design doc classifies the Site Event
/// Log Viewer and Parked Message Management as <b>Deployment Role</b>, but both
/// pages were annotated only <c>[Authorize]</c> (any authenticated user) — a
/// non-Deployment user who followed the nav link could query event logs and
@@ -20,6 +20,7 @@ public class MonitoringAuthorizationTests
.Cast<AuthorizeAttribute>()
.FirstOrDefault();
/// <summary>Verifies that the event logs page requires the Deployment authorization policy.</summary>
[Fact]
public void EventLogsPage_RequiresDeploymentPolicy()
{
@@ -29,6 +30,7 @@ public class MonitoringAuthorizationTests
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
}
/// <summary>Verifies that the parked messages page requires the Deployment authorization policy.</summary>
[Fact]
public void ParkedMessagesPage_RequiresDeploymentPolicy()
{
@@ -38,6 +40,7 @@ public class MonitoringAuthorizationTests
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
}
/// <summary>Verifies that the health dashboard is intentionally open to all authenticated roles.</summary>
[Fact]
public void HealthDashboard_IsIntentionallyAllAuthenticatedRoles()
{
@@ -6,8 +6,7 @@ using AuditLogPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.Audi
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// Unit tests for <see cref="AuditLogPage.BuildExportUrl"/> (#23 M7-T14 /
/// Bundle F). Builds the <c>?...</c> querystring the Export-CSV link points
/// Unit tests for <see cref="AuditLogPage.BuildExportUrl"/> (audit log export). Builds the <c>?...</c> querystring the Export-CSV link points
/// at; the same conversion is round-tripped on the server side by
/// <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Audit.AuditExportEndpoints.ParseFilter"/>.
/// These tests pin the no-filter base path + the round-trip back through
@@ -15,6 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// </summary>
public class AuditLogPageExportUrlTests
{
/// <summary>Verifies that a null filter yields the base export path.</summary>
[Fact]
public void BuildExportUrl_NullFilter_ReturnsBasePath()
{
@@ -22,6 +22,7 @@ public class AuditLogPageExportUrlTests
Assert.Equal("/api/centralui/audit/export", url);
}
/// <summary>Verifies that an empty filter yields the base export path.</summary>
[Fact]
public void BuildExportUrl_EmptyFilter_ReturnsBasePath()
{
@@ -31,6 +32,7 @@ public class AuditLogPageExportUrlTests
Assert.Equal("/api/centralui/audit/export", url);
}
/// <summary>Verifies that a filter with every field set round-trips through the export URL.</summary>
[Fact]
public void BuildExportUrl_AllFiltersSet_RoundTrips()
{
@@ -62,6 +64,7 @@ public class AuditLogPageExportUrlTests
Assert.Equal("2026-05-20T23:59:59.0000000Z", query["to"]);
}
/// <summary>Verifies that a filter with only a channel set omits all other query parameters.</summary>
[Fact]
public void BuildExportUrl_OnlyChannelSet_OmitsOtherParams()
{
@@ -75,6 +78,7 @@ public class AuditLogPageExportUrlTests
Assert.Equal("Notification", query["channel"]);
}
/// <summary>Verifies that setting an execution id emits the execution id query parameter.</summary>
[Fact]
public void BuildExportUrl_ExecutionIdSet_EmitsExecutionIdParam()
{
@@ -89,6 +93,7 @@ public class AuditLogPageExportUrlTests
Assert.Equal(exec.ToString(), query["executionId"]);
}
/// <summary>Verifies that setting a parent execution id emits the parent execution id query parameter.</summary>
[Fact]
public void BuildExportUrl_ParentExecutionIdSet_EmitsParentExecutionIdParam()
{
@@ -103,10 +108,11 @@ public class AuditLogPageExportUrlTests
Assert.Equal(parent.ToString(), query["parentExecutionId"]);
}
/// <summary>Verifies that multi-value dimensions emit repeated query parameters.</summary>
[Fact]
public void BuildExportUrl_MultiValueDimensions_EmitRepeatedParams()
{
// Task 9: each multi-value dimension emits one repeated query-string key
// Each multi-value dimension emits one repeated query-string key
// per selected value so the export endpoint's ParseFilter sees them all.
var filter = new AuditLogQueryFilter(
Channels: new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound },
@@ -28,7 +28,7 @@ using AuditLogPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.Audi
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// Permission-gating tests for the Audit Log surface (#23 M7-T15 / Bundle G).
/// Permission-gating tests for the Audit Log surface (Bundle G).
///
/// <para>
/// Bundle G introduces two new policies:
@@ -38,7 +38,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <item><c>AuditExport</c> — additional gate on the Export-CSV button and
/// the streaming export endpoint.</item>
/// </list>
/// Post Task 1.7: <c>AuditExport</c> is satisfied only by the
/// After the role consolidation, <c>AuditExport</c> is satisfied only by the
/// <c>Administrator</c> role (which absorbed the former <c>Audit</c> role);
/// <c>OperationalAudit</c> is additionally satisfied by the <c>Viewer</c> role
/// (the home of the former <c>AuditReadOnly</c> role) — Viewer reads but cannot
@@ -48,6 +48,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// </summary>
public class AuditLogPagePermissionTests : BunitContext
{
/// <summary>Initializes the test context and registers the fake authentication and service stand-ins.</summary>
public AuditLogPagePermissionTests()
{
// The page hosts AuditResultsGrid, whose OnAfterRenderAsync wires the
@@ -71,7 +72,7 @@ public class AuditLogPagePermissionTests : BunitContext
// a permitted render is exercised end-to-end.
Services.AddSingleton(Substitute.For<ISiteRepository>());
Services.AddSingleton(Substitute.For<IAuditLogQueryService>());
// M6 (K15): the page now injects IKpiHistoryQueryService for the Trends
// The page now injects IKpiHistoryQueryService for the Trends
// panel — register a stand-in so a permitted render is exercised end-to-end.
Services.AddSingleton(Substitute.For<IKpiHistoryQueryService>());
}
@@ -109,6 +110,8 @@ public class AuditLogPagePermissionTests : BunitContext
// so we verify the attribute is present + the policy denies a principal
// that holds none of the permitting roles.
/// <summary>Verifies that a user without the OperationalAudit permission is denied by the policy.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WithoutOperationalAudit_PolicyDenies()
{
@@ -127,6 +130,7 @@ public class AuditLogPagePermissionTests : BunitContext
Assert.False(result.Succeeded);
}
/// <summary>Verifies that the Audit Log page carries the OperationalAudit authorize attribute.</summary>
[Fact]
public void AuditLogPage_HasOperationalAuditAuthorizeAttribute()
{
@@ -140,6 +144,7 @@ public class AuditLogPagePermissionTests : BunitContext
Assert.Contains(attributes, a => a.Policy == AuthorizationPolicies.OperationalAudit);
}
/// <summary>Verifies that the Configuration Audit Log page carries the OperationalAudit authorize attribute.</summary>
[Fact]
public void ConfigurationAuditLogPage_HasOperationalAuditAuthorizeAttribute()
{
@@ -159,13 +164,14 @@ public class AuditLogPagePermissionTests : BunitContext
// Test 2 + 3: Export button visibility split.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that a user with OperationalAudit but without AuditExport sees the page with the export button hidden.</summary>
[Fact]
public void WithOperationalAudit_NoAuditExport_PageRenders_ExportButtonHidden()
{
// The "Administrator" role grants OperationalAudit + AuditExport, so we
// test the split by handing the user ONLY the "Viewer" role, which maps
// to OperationalAudit (read) but NOT AuditExport — the preserved
// half-SoD after the Task 1.7 AuditReadOnly→Viewer collapse
// half-SoD after the AuditReadOnly→Viewer collapse
// (see AuthorizationPolicies).
var cut = RenderAuditLogPage("Viewer");
@@ -178,6 +184,7 @@ public class AuditLogPagePermissionTests : BunitContext
});
}
/// <summary>Verifies that a user with both OperationalAudit and AuditExport sees the page with the export button visible.</summary>
[Fact]
public void WithOperationalAudit_AndAuditExport_PageRenders_ExportButtonVisible()
{
@@ -190,6 +197,7 @@ public class AuditLogPagePermissionTests : BunitContext
});
}
/// <summary>Verifies that an Administrator sees the page and the export button.</summary>
[Fact]
public void AdminUser_SeesPage_AndExportButton()
{
@@ -208,6 +216,8 @@ public class AuditLogPagePermissionTests : BunitContext
// Test 4 + 5: Endpoint-level enforcement.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that the export endpoint returns 403 for a caller without AuditExport.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuditExportEndpoint_WithoutAuditExport_Returns403()
{
@@ -221,6 +231,8 @@ public class AuditLogPagePermissionTests : BunitContext
}
}
/// <summary>Verifies that the export endpoint returns 200 for a caller with AuditExport.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuditExportEndpoint_WithAuditExport_Returns200()
{
@@ -232,6 +244,8 @@ public class AuditLogPagePermissionTests : BunitContext
}
}
/// <summary>Verifies that the export endpoint returns 200 for an Administrator alone.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuditExportEndpoint_AdminAlone_Returns200()
{
@@ -245,6 +259,8 @@ public class AuditLogPagePermissionTests : BunitContext
}
}
/// <summary>Verifies that the export endpoint returns 403 for a Viewer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuditExportEndpoint_Viewer_Returns403()
{
@@ -313,12 +329,17 @@ public class AuditLogPagePermissionTests : BunitContext
{
public const string SchemeName = "FakeAuth";
/// <summary>Initializes the handler with the standard authentication handler dependencies.</summary>
/// <param name="options">The options monitor for the fake handler.</param>
/// <param name="logger">The logger factory.</param>
/// <param name="encoder">The URL encoder.</param>
public FakeAuthHandler(
IOptionsMonitor<FakeAuthHandlerOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder) { }
/// <inheritdoc />
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new List<Claim> { new(ClaimTypes.Name, "test-user") };
@@ -335,6 +356,7 @@ public class AuditLogPagePermissionTests : BunitContext
private sealed class FakeAuthHandlerOptions : AuthenticationSchemeOptions
{
/// <summary>Gets or sets the roles granted to the fake authenticated user.</summary>
public string[] Roles { get; set; } = Array.Empty<string>();
}
}
@@ -16,9 +16,8 @@ using NavMenu = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Layout.NavMenu;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// Scaffold tests for the new Audit Log page (#23 M7-T1) and the Audit
/// nav group that hosts both it and the renamed Configuration Audit Log
/// (#23 M7 Bundle A).
/// Scaffold tests for the new Audit Log page and the Audit
/// nav group that hosts both it and the renamed Configuration Audit Log.
///
/// These are render-only smoke tests — the filter bar and results grid
/// are intentional placeholders that Bundle B fills in. The tests pin
@@ -27,6 +26,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// </summary>
public class AuditLogPageScaffoldTests : BunitContext
{
/// <summary>Configures bUnit's JS interop in loose mode so unconfigured grid interop calls are no-ops.</summary>
public AuditLogPageScaffoldTests()
{
// The page hosts AuditResultsGrid, whose OnAfterRenderAsync wires the
@@ -63,7 +63,7 @@ public class AuditLogPageScaffoldTests : BunitContext
// Provide stand-ins so the scaffold smoke tests still render the page.
Services.AddSingleton(Substitute.For<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.ISiteRepository>());
Services.AddSingleton(_queryService);
// M6 (K15): the page now injects IKpiHistoryQueryService for the Trends
// The page now injects IKpiHistoryQueryService for the Trends
// panel. Provide a stand-in so the scaffold/drill-in renders still work.
Services.AddSingleton(Substitute.For<IKpiHistoryQueryService>());
@@ -73,7 +73,7 @@ public class AuditLogPageScaffoldTests : BunitContext
nav.NavigateTo($"/audit/log?{query}");
}
// Bundle G (#23 M7-T15): the page now hosts an in-component
// The page now hosts an in-component
// AuthorizeView around the Export-CSV button, so the page MUST
// render inside a CascadingAuthenticationState. The router supplies
// this in production; bUnit hosts the page directly so we wrap it
@@ -109,7 +109,7 @@ public class AuditLogPageScaffoldTests : BunitContext
/// <summary>
/// Asserts the named rail section is present. After the ZB.MOM.WW.Theme
/// adoption (Task 2.4) nav sections are the kit's <c>NavRailSection</c> —
/// adoption nav sections are the kit's <c>NavRailSection</c> —
/// native <c>&lt;details class="rail-section"&gt;</c> disclosures that are
/// open by default, so a section's items are already in the DOM (no click
/// needed, no JS). This helper just confirms the section's
@@ -122,6 +122,7 @@ public class AuditLogPageScaffoldTests : BunitContext
s => s.TextContent.Contains(title, StringComparison.Ordinal));
}
/// <summary>Verifies that the Audit Log page renders its page heading.</summary>
[Fact]
public void AuditLogPage_Renders_PageHeading()
{
@@ -136,6 +137,7 @@ public class AuditLogPageScaffoldTests : BunitContext
});
}
/// <summary>Verifies that the nav menu contains an Audit group with a link to the Audit Log page.</summary>
[Fact]
public void NavMenu_Contains_AuditGroup_With_AuditLog_Link()
{
@@ -149,6 +151,7 @@ public class AuditLogPageScaffoldTests : BunitContext
});
}
/// <summary>Verifies that the nav menu lists the Configuration Audit Log link under the Audit group.</summary>
[Fact]
public void NavMenu_Contains_ConfigurationAuditLog_Link_UnderAuditGroup()
{
@@ -174,9 +177,10 @@ public class AuditLogPageScaffoldTests : BunitContext
}
// ─────────────────────────────────────────────────────────────────────────
// Bundle D — query-string drill-in parsing (#23 M7-T10..T12)
// Query-string drill-in parsing
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Verifies that navigating with a correlation id applies the filter and loads results automatically.</summary>
[Fact]
public void NavigateWithCorrelationId_AppliesFilter_AndAutoLoads()
{
@@ -197,6 +201,7 @@ public class AuditLogPageScaffoldTests : BunitContext
});
}
/// <summary>Verifies that navigating with an execution id applies the filter and loads results automatically.</summary>
[Fact]
public void NavigateWithExecutionIdParam_AppliesFilter_AndAutoLoads()
{
@@ -219,6 +224,7 @@ public class AuditLogPageScaffoldTests : BunitContext
});
}
/// <summary>Verifies that an unparseable execution id is silently dropped and nothing loads automatically.</summary>
[Fact]
public void NavigateWithUnparseableExecutionIdParam_IsSilentlyDropped_NoAutoLoad()
{
@@ -235,6 +241,7 @@ public class AuditLogPageScaffoldTests : BunitContext
Arg.Any<CancellationToken>());
}
/// <summary>Verifies that navigating with a parent execution id applies the filter and loads results automatically.</summary>
[Fact]
public void NavigateWithParentExecutionIdParam_AppliesFilter_AndAutoLoads()
{
@@ -257,6 +264,7 @@ public class AuditLogPageScaffoldTests : BunitContext
});
}
/// <summary>Verifies that an unparseable parent execution id is silently dropped and nothing loads automatically.</summary>
[Fact]
public void NavigateWithUnparseableParentExecutionIdParam_IsSilentlyDropped_NoAutoLoad()
{
@@ -274,6 +282,7 @@ public class AuditLogPageScaffoldTests : BunitContext
Arg.Any<CancellationToken>());
}
/// <summary>Verifies that navigating with a target parameter applies the target filter.</summary>
[Fact]
public void NavigateWithTargetParam_AppliesTargetFilter()
{
@@ -292,6 +301,7 @@ public class AuditLogPageScaffoldTests : BunitContext
});
}
/// <summary>Verifies that navigating with a site parameter applies the site filter.</summary>
[Fact]
public void NavigateWithSiteParam_AppliesSiteFilter()
{
@@ -311,10 +321,11 @@ public class AuditLogPageScaffoldTests : BunitContext
});
}
/// <summary>Verifies that navigating with a status parameter applies the status filter.</summary>
[Fact]
public void NavigateWithStatusParam_AppliesStatusFilter()
{
// Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills
// The Health-dashboard Audit error-rate tile drills
// in with ?status=Failed. The page parses the enum (case-insensitive),
// builds an AuditLogQueryFilter with Status set, and auto-loads.
_queryService = Substitute.For<IAuditLogQueryService>();
@@ -333,6 +344,7 @@ public class AuditLogPageScaffoldTests : BunitContext
});
}
/// <summary>Verifies that an unknown status value is silently dropped and nothing loads automatically.</summary>
[Fact]
public void NavigateWithUnknownStatusParam_IsSilentlyDropped_NoAutoLoad()
{
@@ -350,6 +362,7 @@ public class AuditLogPageScaffoldTests : BunitContext
Arg.Any<CancellationToken>());
}
/// <summary>Verifies that navigating without parameters leaves the filter empty and does not load automatically.</summary>
[Fact]
public void NavigateWithNoParams_LeavesFilterEmpty_NoAutoLoad()
{
@@ -15,7 +15,7 @@ using AuditLogPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.Audi
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// Tests for the M6 (K15) Trends panel added to the Audit Log page — one
/// Tests for the Trends panel added to the Audit Log page — one
/// <c>KpiTrendChart</c> per AuditLog global metric, over a 24h/7d window. The
/// panel is best-effort: a failing <see cref="IKpiHistoryQueryService"/> must
/// degrade the affected charts without breaking the audit query UI (the filter
@@ -37,6 +37,7 @@ public class AuditLogPageTrendTests : BunitContext
{
private static readonly DateTime Base = new(2026, 6, 15, 10, 0, 0, DateTimeKind.Utc);
/// <summary>Initializes a new instance of the <see cref="AuditLogPageTrendTests"/> class.</summary>
public AuditLogPageTrendTests()
{
// AuditResultsGrid's OnAfterRenderAsync wires its column resize/reorder UX
@@ -84,6 +85,7 @@ public class AuditLogPageTrendTests : BunitContext
return host.FindComponent<AuditLogPage>();
}
/// <summary>Verifies the case: trends Panel, with Series, renders Charts With Polyline.</summary>
[Fact]
public void TrendsPanel_WithSeries_RendersChartsWithPolyline()
{
@@ -118,11 +120,12 @@ public class AuditLogPageTrendTests : BunitContext
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies the case: trends Panel, long Range Windows, render And Refetch With Matching Span.</summary>
[Fact]
public void TrendsPanel_LongRangeWindows_RenderAndRefetchWithMatchingSpan()
{
// The 30d (720h) / 90d (2160h) buttons added for the hourly-rollup path
// (plan #22 T7) must render alongside 24h/7d and, when clicked, re-fetch
// must render alongside 24h/7d and, when clicked, re-fetch
// every series over the corresponding range — the query service then
// transparently routes the long windows to hourly rollups.
var ranges = new List<(DateTime From, DateTime To)>();
@@ -156,6 +159,7 @@ public class AuditLogPageTrendTests : BunitContext
Assert.Contains(ranges, r => Math.Abs((r.To - r.From).TotalHours - 2160) < 0.5));
}
/// <summary>Verifies the case: trends Panel, when Query Service Throws, page Still Renders.</summary>
[Fact]
public void TrendsPanel_WhenQueryServiceThrows_PageStillRenders()
{
@@ -23,7 +23,7 @@ using TransportExportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Des
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design;
/// <summary>
/// bUnit + logic tests for the TransportExport wizard (Component #24, Task T21).
/// bUnit + logic tests for the TransportExport wizard (Transport component).
///
/// <para>
/// Covers the four contract points the design plan calls out:
@@ -54,6 +54,7 @@ public class TransportExportPageTests : BunitContext
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly IBundleExporter _exporter = Substitute.For<IBundleExporter>();
/// <summary>Initializes a new instance of <c>TransportExportPageTests</c>.</summary>
public TransportExportPageTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
@@ -76,7 +77,7 @@ public class TransportExportPageTests : BunitContext
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(new List<SmtpConfiguration>()));
_inboundApiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod>()));
// Empty site/instance defaults — the M8 site/instance picker calls these on load
// Empty site/instance defaults — the site/instance picker calls these on load
// and the DependencyResolver fans out over sites on resolve.
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>()));
@@ -92,7 +93,7 @@ public class TransportExportPageTests : BunitContext
Services.AddSingleton(_siteRepo);
Services.AddSingleton(_exporter);
// DependencyResolver is sealed but its dependencies are the repositories above
// (template/external/notification/inbound + ISiteRepository for M8 site/instance
// (template/external/notification/inbound + ISiteRepository for site/instance
// export) — registering the concrete type is enough.
Services.AddSingleton<DependencyResolver>();
Services.AddSingleton<IOptions<TransportOptions>>(
@@ -117,6 +118,7 @@ public class TransportExportPageTests : BunitContext
// ─────────────────────────────────────────────────────────────────────
// Test 1: Step 1 renders the template tree and every flat artifact group.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that renders step 1 with template tree and artifact checkboxes.</summary>
[Fact]
public void Renders_step1_with_template_tree_and_artifact_checkboxes()
{
@@ -201,6 +203,8 @@ public class TransportExportPageTests : BunitContext
// Test 2: Step 2 shows resolved dependencies — auto-included templates pulled
// in because a seed template composes them.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that step 2 shows resolved dependencies after clicking next.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Step2_shows_resolved_dependencies_after_clicking_next()
{
@@ -252,6 +256,8 @@ public class TransportExportPageTests : BunitContext
// Test 3: Walks the wizard end-to-end and verifies BundleExporter.ExportAsync
// is invoked with the user-selected ids and the authenticated identity.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that step 4 triggers export async with selected artifacts and user identity.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Step4_triggers_ExportAsync_with_selected_artifacts_and_user_identity()
{
@@ -318,6 +324,8 @@ public class TransportExportPageTests : BunitContext
// doesn't model routing, so we verify the policy itself denies the
// principal (the same gate the router consults).
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that page returns unauthorized for user without design role.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Page_returns_unauthorized_for_user_without_Design_role()
{
@@ -336,9 +344,10 @@ public class TransportExportPageTests : BunitContext
}
// ─────────────────────────────────────────────────────────────────────
// Test 5 (M8 E1): Step 1 renders the Sites & Instances group, listing each
// Test 5: Step 1 renders the Sites & Instances group, listing each
// site and (when expanded) its instances.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that renders step 1 sites group with sites and instances.</summary>
[Fact]
public void Renders_step1_sites_group_with_sites_and_instances()
{
@@ -372,10 +381,12 @@ public class TransportExportPageTests : BunitContext
}
// ─────────────────────────────────────────────────────────────────────
// Test 6 (M8 E1): Selecting a site flows its id into ExportSelection.SiteIds;
// Test 6: Selecting a site flows its id into ExportSelection.SiteIds;
// selecting an individual instance flows its id into InstanceIds. Verified at
// the resolver/exporter boundary (the same contract DependencyResolver reads).
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that step 4 export carries selected site and instance ids.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Step4_export_carries_selected_site_and_instance_ids()
{
@@ -441,6 +452,8 @@ public class TransportExportPageTests : BunitContext
// AccountSid (never AuthToken), and ticking one flows its entity id into
// ExportSelection.SmsConfigurationIds — mirroring the SMTP-by-Host contract.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that step 4 export carries selected sms configuration ids.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Step4_export_carries_selected_sms_configuration_ids()
{
@@ -494,6 +507,7 @@ public class TransportExportPageTests : BunitContext
// Test 9 (S10c): CountSecrets counts a populated SMS AuthToken, mirroring
// the SMTP Credentials contribution to the Step 3 secrets banner.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that count secrets includes sms configuration auth token.</summary>
[Fact]
public void CountSecrets_includes_sms_configuration_auth_token()
{
@@ -523,6 +537,7 @@ public class TransportExportPageTests : BunitContext
// Static helpers — exercised directly so the file-naming + secret-count
// contract is unit-pinned independently of the rendering surface.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that build filename produces pattern and sanitises source environment.</summary>
[Fact]
public void BuildFilename_produces_pattern_and_sanitises_source_environment()
{
@@ -532,9 +547,10 @@ public class TransportExportPageTests : BunitContext
}
// ─────────────────────────────────────────────────────────────────────
// Test 7 (M8 C2-SECRET-1): CountSecrets includes site data-connection
// Test 7: CountSecrets includes site data-connection
// PrimaryConfiguration and BackupConfiguration fields in the banner total.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that count secrets includes data connection primary and backup configurations.</summary>
[Fact]
public void CountSecrets_includes_data_connection_primary_and_backup_configurations()
{
@@ -560,6 +576,7 @@ public class TransportExportPageTests : BunitContext
Assert.Equal(2, TransportExportPage.CountSecrets(resolved));
}
/// <summary>Verifies that count secrets counts only non empty data connection configurations.</summary>
[Fact]
public void CountSecrets_counts_only_non_empty_data_connection_configurations()
{
@@ -589,6 +606,7 @@ public class TransportExportPageTests : BunitContext
Assert.Equal(1, TransportExportPage.CountSecrets(resolved));
}
/// <summary>Verifies that count secrets returns zero when no data connections.</summary>
[Fact]
public void CountSecrets_returns_zero_when_no_data_connections()
{
@@ -24,7 +24,7 @@ using TransportImportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Des
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design;
/// <summary>
/// bUnit + logic tests for the TransportImport wizard (Component #24, Task T22).
/// bUnit + logic tests for the TransportImport wizard.
///
/// <para>
/// The wizard has five steps (Upload / Passphrase / Diff / Confirm / Result).
@@ -41,7 +41,7 @@ public class TransportImportPageTests : BunitContext
{
private readonly IBundleImporter _importer = Substitute.For<IBundleImporter>();
private readonly IAuditService _auditService = Substitute.For<IAuditService>();
// M8 E2: the Map step injects ISiteRepository to populate target-site +
// The Map step injects ISiteRepository to populate target-site +
// target-connection dropdowns. Register a substitute so every test (not just
// the Map tests) can render the wizard without a missing-service failure.
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
@@ -49,6 +49,7 @@ public class TransportImportPageTests : BunitContext
/// <summary>Client address the wizard reports for the per-IP unlock throttle (register row 44).</summary>
private const string TestClientIp = "203.0.113.7";
/// <summary>Initializes the test context with stubbed importer, audit, repository and authentication services.</summary>
public TransportImportPageTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
@@ -124,6 +125,7 @@ public class TransportImportPageTests : BunitContext
// ─────────────────────────────────────────────────────────────────────
// Test 1: Step 1 renders the InputFile upload control.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that the wizard renders the step 1 upload input.</summary>
[Fact]
public void Renders_step1_upload_input()
{
@@ -138,6 +140,8 @@ public class TransportImportPageTests : BunitContext
// Test 2: Wrong passphrase increments the failure counter without
// advancing past Step 2.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that a decryption failure increments the passphrase attempt counter.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Decryption_failure_increments_attempt_counter()
{
@@ -169,6 +173,8 @@ public class TransportImportPageTests : BunitContext
// Test 3: After MaxUnlockAttemptsPerSession failures the wizard returns
// to Step 1 with an explanatory error.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that three failed unlock attempts force the user to upload the bundle again.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Three_failed_unlocks_force_reupload()
{
@@ -202,6 +208,8 @@ public class TransportImportPageTests : BunitContext
// Test 4: Confirm step requires an exact match (case-sensitive) on the
// source environment name before Apply is enabled.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that the confirm step requires an exact environment name match.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Confirm_step_requires_exact_environment_name_match()
{
@@ -250,6 +258,8 @@ public class TransportImportPageTests : BunitContext
// Test 5: ApplyAsync is invoked with the chosen resolutions and the
// authenticated user identity.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that the apply step calls the bundle importer's <c>ApplyAsync</c> with the chosen resolutions.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Apply_step_invokes_BundleImporter_ApplyAsync_with_resolutions()
{
@@ -314,6 +324,8 @@ public class TransportImportPageTests : BunitContext
// doesn't model routing, so we verify the policy itself denies the
// principal.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that the page is unauthorized for a user without the Admin role.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Page_returns_unauthorized_for_user_without_Admin_role()
{
@@ -335,6 +347,7 @@ public class TransportImportPageTests : BunitContext
// Test 7 (helper coverage): BuildDefaultResolutions maps each kind to the
// expected default ResolutionAction.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that default resolutions map each conflict kind to the expected action.</summary>
[Fact]
public void BuildDefaultResolutions_maps_kinds_to_actions()
{
@@ -355,10 +368,12 @@ public class TransportImportPageTests : BunitContext
}
// ─────────────────────────────────────────────────────────────────────
// Test 8 (M8 E2): the Map sub-section renders one row per required site /
// Test 8: the Map sub-section renders one row per required site /
// connection mapping with the auto-match defaults pre-selected, and the
// section is hidden entirely when the preview carries no required mappings.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that the map section renders the required rows with auto-match defaults selected.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Map_section_renders_required_rows_with_automatch_defaults()
{
@@ -398,6 +413,8 @@ public class TransportImportPageTests : BunitContext
Assert.Equal("opc-main", selectedConnOpt.GetAttribute("value"));
}
/// <summary>Verifies that the map section is hidden when no mappings are required.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Map_section_hidden_when_no_required_mappings()
{
@@ -418,10 +435,12 @@ public class TransportImportPageTests : BunitContext
}
// ─────────────────────────────────────────────────────────────────────
// Test 9 (M8 E2): the operator's Map choices fold into the BundleNameMap
// Test 9: the operator's Map choices fold into the BundleNameMap
// passed to ApplyAsync — a chosen target → MapToExisting, "Create new" →
// CreateNew. Captured via the substituted IBundleImporter.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that apply passes a <c>BundleNameMap</c> built from the operator's map choices.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Apply_passes_BundleNameMap_built_from_map_choices()
{
@@ -479,9 +498,11 @@ public class TransportImportPageTests : BunitContext
}
// ─────────────────────────────────────────────────────────────────────
// Test 10 (M8 E2): a Modified row whose FieldDiffJson carries a code
// Test 10: a Modified row whose FieldDiffJson carries a code
// lineDiff renders +/- lines via LineDiffView.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that a modified row with a code line diff renders added and removed lines.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Modified_row_with_code_lineDiff_renders_add_and_remove_lines()
{
@@ -533,8 +554,10 @@ public class TransportImportPageTests : BunitContext
}
// ─────────────────────────────────────────────────────────────────────
// Test 11 (M8 E2): truncation marker shows when the lineDiff is truncated.
// Test 11: truncation marker shows when the lineDiff is truncated.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that a modified row with a truncated line diff shows the truncation marker.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Modified_row_with_truncated_lineDiff_shows_truncation_marker()
{
@@ -579,11 +602,13 @@ public class TransportImportPageTests : BunitContext
}
// ─────────────────────────────────────────────────────────────────────
// Test 12 (M8 E2 Fix 1): changing a source site's dropdown to a target
// Test 12: changing a source site's dropdown to a target
// that lacks the previously auto-matched connection name resets that
// connection choice to CreateNew (""), and BuildNameMap therefore emits
// a CreateNew entry — not a MapToExisting for an absent connection.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that changing a site choice resets connection choices the new target site lacks.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OnSiteChoiceChanged_resets_stale_connection_choices_when_new_target_lacks_connection()
{
@@ -639,9 +664,11 @@ public class TransportImportPageTests : BunitContext
}
// ─────────────────────────────────────────────────────────────────────
// Test 13 (M8 E2 Fix 2): BackToUpload clears session/preview/Map state
// Test 13: BackToUpload clears session/preview/Map state
// so a subsequent re-upload flow cannot inherit stale data.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that going back to upload clears the session, preview and map state.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BackToUpload_clears_session_and_preview_state()
{
@@ -715,12 +742,17 @@ public class TransportImportPageTests : BunitContext
/// </summary>
private sealed class StoreBackedImporter : IBundleImporter
{
/// <summary>Gets the backing bundle session store.</summary>
public BundleSessionStore Store { get; } = new(
Microsoft.Extensions.Options.Options.Create(new TransportOptions()),
TimeProvider.System);
/// <summary>Gets the number of times <c>Abandon</c> has been called.</summary>
public int AbandonCalls { get; private set; }
/// <summary>Seeds an encrypted session directly into the store.</summary>
/// <param name="sourceEnv">The source environment name recorded on the session manifest.</param>
/// <returns>The opened session.</returns>
public BundleSession OpenSession(string sourceEnv = "prod-cluster")
{
var seed = BuildEncryptedSession(sourceEnv);
@@ -735,13 +767,16 @@ public class TransportImportPageTests : BunitContext
return Store.Open(session);
}
/// <inheritdoc />
public Task<BundleSession> LoadAsync(
Stream bundleStream, string? passphrase, CancellationToken ct = default, string? clientKey = null)
=> throw new NotSupportedException("These tests seed sessions via OpenSession().");
/// <inheritdoc />
public Task<ImportPreview> PreviewAsync(Guid sessionId, CancellationToken ct = default)
=> Task.FromResult(new ImportPreview(sessionId, new List<ImportPreviewItem>()));
/// <inheritdoc />
public Task<ImportResult> ApplyAsync(
Guid sessionId,
IReadOnlyList<ImportResolution> resolutions,
@@ -760,6 +795,7 @@ public class TransportImportPageTests : BunitContext
AuditEventCorrelation: Guid.NewGuid().ToString()));
}
/// <inheritdoc />
public void Abandon(Guid sessionId)
{
AbandonCalls++;
@@ -777,6 +813,8 @@ public class TransportImportPageTests : BunitContext
}
}
/// <summary>Verifies that disposing the wizard releases its bundle session.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Disposing_the_wizard_releases_its_bundle_session()
{
@@ -798,6 +836,8 @@ public class TransportImportPageTests : BunitContext
Assert.Null(GetField<object?>(cut.Instance, "_session"));
}
/// <summary>Verifies that going back to upload releases the bundle session.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BackToUpload_releases_the_bundle_session()
{
@@ -827,6 +867,8 @@ public class TransportImportPageTests : BunitContext
Assert.All(content, b => Assert.Equal(0, b));
}
/// <summary>Verifies that applying then disposing releases the session exactly once without throwing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Apply_then_dispose_releases_the_session_once_without_throwing()
{
@@ -862,6 +904,8 @@ public class TransportImportPageTests : BunitContext
Assert.Null(importer.Store.Get(session.SessionId));
}
/// <summary>Verifies that disposing one wizard leaves a concurrent circuit's session open.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Disposing_one_wizard_leaves_a_concurrent_circuits_session_open()
{
@@ -942,8 +986,8 @@ public class TransportImportPageTests : BunitContext
/// <summary>
/// Seeds the wizard at Step 2 (Passphrase) with a staged bundle file — the
/// shape after an encrypted-bundle upload completed Step 1's peek and
/// surfaced an ArgumentException ("passphrase required"). CentralUI-031:
/// the wizard now stages the upload to a temp file and only retains the
/// surfaced an ArgumentException ("passphrase required"). The
/// wizard now stages the upload to a temp file and only retains the
/// path on the component, so the test helper writes the bytes to a per-
/// test temp file and sets the path field instead of the byte[] field.
/// </summary>
@@ -22,7 +22,7 @@ using TransportImportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Des
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design;
/// <summary>
/// Regression tests for register row 103 (the P6-6 lesser instance in
/// Regression tests for register row 103 (the lesser instance in
/// <c>TransportImport</c>). <c>TryLoadAsync</c> awaits
/// <see cref="IBundleImporter.LoadAsync"/>, then used to publish the result into
/// <c>_session</c> unconditionally. If the circuit was torn down inside that await
@@ -31,7 +31,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design;
/// <c>BundleSession</c> stayed registered, stranding one of
/// <c>TransportOptions.MaxConcurrentImportSessions</c> (register row 43) for the full
/// session TTL. The fix publishes the session and re-checks disposal under one lock
/// (<c>_sessionLock</c>), mirroring <c>DebugView</c>'s P6-6 fix, so exactly one side
/// (<c>_sessionLock</c>), mirroring <c>DebugView</c>'s fix, so exactly one side
/// (this method or <c>Dispose</c>) abandons it.
/// </summary>
public class TransportImportSessionDisposeRaceTests : BunitContext
@@ -48,6 +48,7 @@ public class TransportImportSessionDisposeRaceTests : BunitContext
private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly List<Guid> _abandoned = new();
/// <summary>Gets the session store used by the test.</summary>
public BundleSessionStore Store { get; } = new(
Options.Create(new TransportOptions()), TimeProvider.System);
@@ -60,8 +61,10 @@ public class TransportImportSessionDisposeRaceTests : BunitContext
get { lock (_abandoned) { return _abandoned.ToList(); } }
}
/// <summary>Releases the load that is blocked waiting for the test to let it finish.</summary>
public void ReleaseLoad() => _gate.TrySetResult();
/// <inheritdoc />
public async Task<BundleSession> LoadAsync(
Stream bundleStream, string? passphrase, CancellationToken ct = default, string? clientKey = null)
{
@@ -89,9 +92,11 @@ public class TransportImportSessionDisposeRaceTests : BunitContext
return Store.Open(session);
}
/// <inheritdoc />
public Task<ImportPreview> PreviewAsync(Guid sessionId, CancellationToken ct = default)
=> throw new NotSupportedException("Not exercised by these tests.");
/// <inheritdoc />
public Task<ImportResult> ApplyAsync(
Guid sessionId,
IReadOnlyList<ImportResolution> resolutions,
@@ -100,6 +105,7 @@ public class TransportImportSessionDisposeRaceTests : BunitContext
BundleNameMap? nameMap = null)
=> throw new NotSupportedException("Not exercised by these tests.");
/// <inheritdoc />
public void Abandon(Guid sessionId)
{
lock (_abandoned) { _abandoned.Add(sessionId); }
@@ -116,6 +122,7 @@ public class TransportImportSessionDisposeRaceTests : BunitContext
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private const string TestClientIp = "203.0.113.7";
/// <summary>Initializes the test class and its fake session service.</summary>
public TransportImportSessionDisposeRaceTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
@@ -181,6 +188,8 @@ public class TransportImportSessionDisposeRaceTests : BunitContext
return task!;
}
/// <summary>Verifies that a component disposed mid-load abandons the session that was just opened.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TryLoadAsync_DisposedMidLoad_AbandonsTheJustOpenedSession()
{
@@ -203,6 +212,8 @@ public class TransportImportSessionDisposeRaceTests : BunitContext
Assert.Null(_importer.Store.Get(_importer.Abandoned[0]));
}
/// <summary>Verifies that loading and then disposing abandons the session exactly once.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TryLoadAsync_ThenDispose_AbandonsTheSessionExactlyOnce()
{
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// bUnit tests for <see cref="ExecutionTreePage"/> (Audit Log ParentExecutionId
/// feature, Task 10). The page is reached via the "View execution chain"
/// feature). The page is reached via the "View execution chain"
/// drill-in at <c>/audit/execution-tree?executionId={guid}</c>. It parses the
/// query-string id, calls <see cref="IAuditLogQueryService.GetExecutionTreeAsync"/>,
/// and hands the flat node list to the <c>ExecutionTree</c> component.
@@ -68,6 +68,7 @@ public class ExecutionTreePageTests : BunitContext
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc));
/// <summary>Verifies that navigating with an execution id calls the service and renders the tree.</summary>
[Fact]
public void NavigateWithExecutionId_CallsService_AndRendersTree()
{
@@ -91,6 +92,7 @@ public class ExecutionTreePageTests : BunitContext
});
}
/// <summary>Verifies that navigating without an execution id renders the guidance prompt and makes no service call.</summary>
[Fact]
public void NavigateWithoutExecutionId_RendersGuidancePrompt_NoServiceCall()
{
@@ -102,6 +104,7 @@ public class ExecutionTreePageTests : BunitContext
_queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that navigating with an unparseable execution id renders the guidance prompt and makes no service call.</summary>
[Fact]
public void NavigateWithUnparseableExecutionId_RendersGuidancePrompt_NoServiceCall()
{
@@ -113,6 +116,7 @@ public class ExecutionTreePageTests : BunitContext
_queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that double-clicking a tree node opens the execution detail modal.</summary>
[Fact]
public void DoubleClickTreeNode_OpensExecutionDetailModal()
{
@@ -147,6 +151,7 @@ public class ExecutionTreePageTests : BunitContext
Arg.Any<CancellationToken>());
}
/// <summary>Verifies that closing the execution detail modal hides it.</summary>
[Fact]
public void ClosingExecutionDetailModal_HidesIt()
{
@@ -175,6 +180,7 @@ public class ExecutionTreePageTests : BunitContext
Assert.Empty(cut.FindAll("[data-test=\"execution-detail-modal\"]")));
}
/// <summary>Verifies that the page carries the operational-audit authorize attribute.</summary>
[Fact]
public void ExecutionTreePage_HasOperationalAuditAuthorizeAttribute()
{
@@ -26,7 +26,7 @@ using HealthPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.H
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// bUnit rendering tests for the Health Monitoring dashboard (Task 24).
/// bUnit rendering tests for the Health Monitoring dashboard.
///
/// Scope: the Notification Outbox KPI tile row added to the Health dashboard.
/// <see cref="ICentralHealthAggregator"/> is an interface (mockable), but
@@ -45,13 +45,14 @@ public class HealthPageTests : BunitContext
new("k", true, null, QueueDepth: 12, StuckCount: 4, ParkedCount: 3,
DeliveredLastInterval: 88, OldestPendingAge: TimeSpan.FromMinutes(6));
// Site Call Audit (#22) Task 7 — mutable scripted Site Call KPI reply. Tests
// 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);
/// <summary>Initializes the test context and registers the services and actor stand-ins the Health page requires.</summary>
public HealthPageTests()
{
_comms = new CommunicationService(
@@ -65,7 +66,7 @@ public class HealthPageTests : BunitContext
_comms.SetSiteCallAudit(siteCallAudit);
Services.AddSingleton(_comms);
// arch-review WP2.4 — the page no longer queries CommunicationService /
// The page no longer queries CommunicationService /
// IAuditLogQueryService per circuit; it reads the process-level memoized
// KPI cache. The real cache is registered here (not a substitute) so the
// scripted-actor seam above is still what actually answers.
@@ -78,7 +79,7 @@ public class HealthPageTests : BunitContext
.Returns(new Dictionary<string, SiteHealthState>());
Services.AddSingleton(aggregator);
// M6 K16 — the Health page now injects IKpiHistoryQueryService to feed the
// The Health page now injects IKpiHistoryQueryService to feed the
// per-site Site Health Trends panel. Stub it with a known non-empty series
// so the page resolves the dependency and the trend charts have data; the
// dedicated trend tests below seed sites / override behaviour.
@@ -94,12 +95,12 @@ public class HealthPageTests : BunitContext
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>()));
Services.AddSingleton(siteRepo);
// PLAN-01 Task 14 — the Health page now shows the metrics-stale badge with
// The Health page now shows the metrics-stale badge with
// a tooltip naming the configured MetricsStaleTimeout, so it injects the
// health-monitoring options.
Services.AddSingleton(Options.Create(new HealthMonitoringOptions()));
// Audit Log (#23) M7 Bundle E — the Health page now also fetches the
// The Health page now also fetches the
// Audit KPI snapshot. Stub it with an empty point-in-time reading so
// the existing assertions (Notification Outbox tiles, Online/Offline
// counts) keep passing; tests that target the Audit tiles set their
@@ -125,7 +126,7 @@ public class HealthPageTests : BunitContext
// policy is evaluated; force the real one (same note as NavMenuTests).
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
// The cluster cards embed CentralFailoverControl (Task 10), which injects these two.
// The cluster cards embed CentralFailoverControl, which injects these two.
// The page's DI graph genuinely requires them in production, so the test supplies
// them rather than the component making its dependencies optional. Behaviour of the
// control itself is covered by HealthFailoverButtonTests.
@@ -136,7 +137,7 @@ public class HealthPageTests : BunitContext
/// <summary>
/// Renders the dashboard the way the app does — inside a
/// <see cref="CascadingAuthenticationState"/>, which the real layout supplies. The
/// cluster cards contain an AuthorizeView (the Task 10 failover control), and
/// cluster cards contain an AuthorizeView (the failover control), and
/// AuthorizeView throws without that cascading value.
/// </summary>
private IRenderedComponent<HealthPage> RenderHealthPage()
@@ -151,6 +152,7 @@ public class HealthPageTests : BunitContext
return host.FindComponent<HealthPage>();
}
/// <summary>Verifies that the Notification Outbox KPI tiles render with the supplied values.</summary>
[Fact]
public void Renders_OutboxKpiTiles_WithValues()
{
@@ -170,6 +172,7 @@ public class HealthPageTests : BunitContext
});
}
/// <summary>Verifies that the dashboard renders a link to the notification KPIs page.</summary>
[Fact]
public void RendersLinkToTheNotificationKpisPage()
{
@@ -178,6 +181,7 @@ public class HealthPageTests : BunitContext
Assert.Contains("View details", link.TextContent);
}
/// <summary>Verifies that the audit KPI tiles render with the supplied values.</summary>
[Fact]
public void Renders_AuditKpiTiles_WithValues()
{
@@ -207,6 +211,7 @@ public class HealthPageTests : BunitContext
});
}
/// <summary>Verifies that the Site Call KPI tiles render with the supplied values.</summary>
[Fact]
public void Renders_SiteCallKpiTiles_WithValues()
{
@@ -227,6 +232,7 @@ public class HealthPageTests : BunitContext
});
}
/// <summary>Verifies that the dashboard renders a link to the Site Calls report page.</summary>
[Fact]
public void RendersLinkToTheSiteCallsReportPage()
{
@@ -235,6 +241,7 @@ public class HealthPageTests : BunitContext
Assert.Contains("View details", link.TextContent);
}
/// <summary>Verifies that a Site Call KPI failure shows a graceful fallback.</summary>
[Fact]
public void SiteCallKpiFailure_ShowsGracefulFallback()
{
@@ -254,6 +261,7 @@ public class HealthPageTests : BunitContext
});
}
/// <summary>Verifies that a Notification Outbox KPI failure shows a graceful fallback.</summary>
[Fact]
public void OutboxKpiFailure_ShowsGracefulFallback()
{
@@ -271,6 +279,7 @@ public class HealthPageTests : BunitContext
});
}
/// <summary>Verifies that the site health trends panel and chart render for the selected site.</summary>
[Fact]
public void Renders_SiteHealthTrends_PanelAndChart_ForSelectedSite()
{
@@ -297,6 +306,7 @@ public class HealthPageTests : BunitContext
});
}
/// <summary>Verifies that a site health trends failure does not break the dashboard.</summary>
[Fact]
public void SiteHealthTrendsFailure_DoesNotBreakDashboard()
{
@@ -324,10 +334,11 @@ public class HealthPageTests : BunitContext
});
}
/// <summary>Verifies that the metrics-stale badge renders for an online site whose metrics are stale.</summary>
[Fact]
public void Renders_MetricsStaleBadge_ForOnlineButStaleSite()
{
// PLAN-01 Task 14: a site that is online (heartbeating) but whose report
// A site that is online (heartbeating) but whose report
// pipeline died shows a distinct "Metrics stale" warning badge.
SeedStates(new SiteHealthState
{
@@ -352,6 +363,7 @@ public class HealthPageTests : BunitContext
// Sustained LocalDb replication backlog badge (register row 33)
// ------------------------------------------------------------------
/// <summary>Verifies that the replication backlog badge renders when the backlog is sustained past the dwell.</summary>
[Fact]
public void Renders_ReplicationBacklogBadge_WhenBacklogSustainedPastTheDwell()
{
@@ -373,6 +385,7 @@ public class HealthPageTests : BunitContext
});
}
/// <summary>Verifies that the replication backlog badge is not rendered during a purge burst.</summary>
[Fact]
public void DoesNotRender_ReplicationBacklogBadge_DuringAPurgeBurst()
{
@@ -394,6 +407,7 @@ public class HealthPageTests : BunitContext
Assert.DoesNotContain("data-test=\"localdb-backlog-badge\"", cut.Markup);
}
/// <summary>Verifies that the replication backlog badge is not rendered for an unreplicated site.</summary>
[Fact]
public void DoesNotRender_ReplicationBacklogBadge_ForAnUnreplicatedSite()
{
@@ -413,6 +427,7 @@ public class HealthPageTests : BunitContext
Assert.DoesNotContain("data-test=\"localdb-backlog-badge\"", cut.Markup);
}
/// <summary>Verifies that the replication backlog badge is not rendered for an offline site.</summary>
[Fact]
public void DoesNotRender_ReplicationBacklogBadge_ForAnOfflineSite()
{
@@ -432,6 +447,7 @@ public class HealthPageTests : BunitContext
Assert.DoesNotContain("data-test=\"localdb-backlog-badge\"", cut.Markup);
}
/// <summary>Verifies that the LocalDb backlog trend chart renders.</summary>
[Fact]
public void Renders_LocalDbBacklogTrendChart()
{
@@ -448,9 +464,10 @@ public class HealthPageTests : BunitContext
// ----- Register row 73: the "No self-heal" badge -----------------------------------
//
// Tri-state, and only the explicit false is badged. Null means "not reported" — the
// central self-report row, and every site node on a pre-row-73 build — so badging null
// central self-report row, and every site node on an older build — so badging null
// would light up the whole fleet the moment central is upgraded ahead of the sites.
/// <summary>Verifies that the startup reconciler badge renders when the site reports it is missing.</summary>
[Fact]
public void Renders_StartupReconcilerBadge_WhenTheSiteReportsItIsMissing()
{
@@ -468,6 +485,7 @@ public class HealthPageTests : BunitContext
Assert.Contains("data-test=\"startup-reconciler-badge\"", cut.Markup));
}
/// <summary>Verifies that the startup reconciler badge is not rendered when the reconciler is running.</summary>
[Fact]
public void DoesNotRender_StartupReconcilerBadge_WhenTheReconcilerIsRunning()
{
@@ -485,6 +503,7 @@ public class HealthPageTests : BunitContext
Assert.DoesNotContain("data-test=\"startup-reconciler-badge\"", cut.Markup);
}
/// <summary>Verifies that the startup reconciler badge is not rendered when the status is not reported.</summary>
[Fact]
public void DoesNotRender_StartupReconcilerBadge_WhenNotReported()
{
@@ -504,6 +523,7 @@ public class HealthPageTests : BunitContext
Assert.DoesNotContain("data-test=\"startup-reconciler-badge\"", cut.Markup);
}
/// <summary>Verifies that the startup reconciler badge is not rendered when the site has no report yet.</summary>
[Fact]
public void DoesNotRender_StartupReconcilerBadge_WhenTheSiteHasNoReportYet()
{
@@ -521,6 +541,7 @@ public class HealthPageTests : BunitContext
Assert.DoesNotContain("data-test=\"startup-reconciler-badge\"", cut.Markup);
}
/// <summary>Verifies that the startup reconciler badge is not rendered for an offline site.</summary>
[Fact]
public void DoesNotRender_StartupReconcilerBadge_ForAnOfflineSite()
{
@@ -559,11 +580,12 @@ public class HealthPageTests : BunitContext
StartupReconcilerActive = active,
};
// ----- Review finding M1: per-node deployed-secrets mirror failures ------------------
// ----- Per-node deployed-secrets mirror failures ------------------
//
// The standby's failures ride the active node's report; the card must name the node,
// the secret and the message. Null ("no data") and all-healthy nodes render nothing.
/// <summary>Verifies that secret mirror failures render labelled by node.</summary>
[Fact]
public void Renders_SecretMirrorFailures_LabelledByNode()
{
@@ -598,8 +620,10 @@ public class HealthPageTests : BunitContext
});
}
/// <summary>Verifies that no secret mirror warning renders when no node is failing.</summary>
/// <param name="reported">Whether the site reports secret mirror status at all.</param>
[Theory]
[InlineData(false)] // null = "no data" (central self-report, pre-M1 site)
[InlineData(false)] // null = "no data" (central self-report, older site)
[InlineData(true)] // every node healthy
public void DoesNotRender_SecretMirrorWarning_WhenNoNodeIsFailing(bool reported)
{
@@ -623,6 +647,7 @@ public class HealthPageTests : BunitContext
Assert.DoesNotContain("data-test=\"secret-mirror-failures\"", cut.Markup);
}
/// <summary>Verifies that the offline-since time renders for an offline site with a status change.</summary>
[Fact]
public void Renders_OfflineSince_ForOfflineSiteWithStatusChange()
{
@@ -688,6 +713,7 @@ public class HealthPageTests : BunitContext
};
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -703,6 +729,8 @@ public class HealthPageTests : BunitContext
/// </summary>
private sealed class ScriptedOutboxActor : ReceiveActor
{
/// <summary>Initializes the actor with the test whose scripted reply it returns.</summary>
/// <param name="test">The owning test that holds the scripted reply.</param>
public ScriptedOutboxActor(HealthPageTests test)
{
Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply));
@@ -712,11 +740,13 @@ public class HealthPageTests : BunitContext
/// <summary>
/// Stand-in for the Site Call Audit actor. Replies to the KPI request with
/// the test's currently-scripted response. Also handles the per-node KPI
/// request (T6: M5.2) with an empty-nodes success reply so the Health page
/// request with an empty-nodes success reply so the Health page
/// can complete initialization without a 30-second Ask timeout.
/// </summary>
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
{
/// <summary>Initializes the actor with the test whose scripted replies it returns.</summary>
/// <param name="test">The owning test that holds the scripted replies.</param>
public ScriptedSiteCallAuditActor(HealthPageTests test)
{
Receive<SiteCallKpiRequest>(_ => Sender.Tell(test._siteCallKpiReply));
@@ -48,7 +48,7 @@ public class NotificationKpisPageTests : BunitContext
DeliveredLastInterval: 9, OldestPendingAge: TimeSpan.FromMinutes(7)),
});
// KPI-history trend service (K13 / T11). Default substitute returns a
// KPI-history trend service Default substitute returns a
// 3-point series for any (source, metric, scope, …) tuple so every chart
// renders a polyline; individual tests can re-stub it (e.g. to throw).
private readonly IKpiHistoryQueryService _kpiHistory = Substitute.For<IKpiHistoryQueryService>();
@@ -60,6 +60,7 @@ public class NotificationKpisPageTests : BunitContext
new(DateTime.UtcNow.AddHours(-1), 4),
};
/// <summary>Initializes a new instance of the <c>NotificationKpisPageTests</c> class.</summary>
public NotificationKpisPageTests()
{
_comms = new CommunicationService(
@@ -71,7 +72,7 @@ public class NotificationKpisPageTests : BunitContext
Services.AddSingleton(_comms);
// arch-review WP2.4 — the page reads the process-level memoized KPI cache
// The page reads the process-level memoized KPI cache
// rather than querying CommunicationService per circuit. The real cache is
// registered so the scripted-actor seam above still answers the queries.
Services.AddSingleton<IKpiSnapshotCache>(sp => new KpiSnapshotCache(
@@ -104,6 +105,8 @@ public class NotificationKpisPageTests : BunitContext
Services.AddTestConfigurationMutations();
}
/// <summary>Verifies that the page requires the deployment authorization policy.</summary>
/// <returns>The result.</returns>
[Fact]
public void Page_RequiresDeploymentPolicy()
{
@@ -116,6 +119,8 @@ public class NotificationKpisPageTests : BunitContext
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
}
/// <summary>Verifies that the page renders the global KPI tiles and the per-site rows.</summary>
/// <returns>The result.</returns>
[Fact]
public void RendersGlobalTilesAndPerSiteRows()
{
@@ -130,6 +135,8 @@ public class NotificationKpisPageTests : BunitContext
});
}
/// <summary>Verifies that the page shows an error when the global KPI query fails.</summary>
/// <returns>The result.</returns>
[Fact]
public void ShowsKpiError_WhenGlobalKpiQueryFails()
{
@@ -141,6 +148,8 @@ public class NotificationKpisPageTests : BunitContext
cut.WaitForAssertion(() => Assert.Contains("kpi down", cut.Markup));
}
/// <summary>Verifies that the page shows a per-site error, without the global error, when the per-site KPI query fails.</summary>
/// <returns>The result.</returns>
[Fact]
public void ShowsPerSiteError_WhenPerSiteKpiQueryFails()
{
@@ -159,6 +168,8 @@ public class NotificationKpisPageTests : BunitContext
});
}
/// <summary>Verifies that the page shows the per-site empty state when there are no sites.</summary>
/// <returns>The result.</returns>
[Fact]
public void ShowsPerSiteEmptyState_WhenNoSites()
{
@@ -170,6 +181,8 @@ public class NotificationKpisPageTests : BunitContext
cut.WaitForAssertion(() => Assert.Contains("No per-site activity", cut.Markup));
}
/// <summary>Verifies that the page renders trend charts when history series are available.</summary>
/// <returns>The result.</returns>
[Fact]
public void RendersTrendCharts_WhenSeriesAvailable()
{
@@ -186,6 +199,8 @@ public class NotificationKpisPageTests : BunitContext
});
}
/// <summary>Verifies that a trend query failure for one metric degrades only that metric's chart.</summary>
/// <returns>The result.</returns>
[Fact]
public void PartialTrendFailure_IsolatesToTheFailingMetric()
{
@@ -224,6 +239,8 @@ public class NotificationKpisPageTests : BunitContext
});
}
/// <summary>Verifies that a failing trend query does not break the page.</summary>
/// <returns>The result.</returns>
[Fact]
public void TrendQueryFailure_DoesNotBreakPage()
{
@@ -249,6 +266,7 @@ public class NotificationKpisPageTests : BunitContext
});
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -261,11 +279,13 @@ public class NotificationKpisPageTests : BunitContext
/// <summary>
/// Stand-in for the notification-outbox actor. Replies to each KPI message
/// type with the test's currently-scripted response. Also handles the per-node
/// KPI request (T6: M5.2) with an empty-nodes success reply so the page can
/// KPI request with an empty-nodes success reply so the page can
/// complete initialization without a 30-second Ask timeout.
/// </summary>
private sealed class ScriptedOutboxActor : ReceiveActor
{
/// <summary>Initializes a new instance of the <c>ScriptedOutboxActor</c> class.</summary>
/// <param name="test">The test.</param>
public ScriptedOutboxActor(NotificationKpisPageTests test)
{
Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply));
@@ -20,6 +20,7 @@ public class NotificationListFormTests : BunitContext
{
private readonly INotificationRepository _repo = Substitute.For<INotificationRepository>();
/// <summary>Initializes the test context with a substitute repository, a channel catalog and test authentication.</summary>
public NotificationListFormTests()
{
Services.AddSingleton(_repo);
@@ -46,6 +47,7 @@ public class NotificationListFormTests : BunitContext
Services.AddTestConfigurationMutations();
}
/// <summary>Verifies that the type selector renders the registered channels.</summary>
[Fact]
public void TypeSelector_RendersRegisteredChannels()
{
@@ -61,6 +63,7 @@ public class NotificationListFormTests : BunitContext
});
}
/// <summary>Verifies that the type selector is enabled when creating a list.</summary>
[Fact]
public void TypeSelector_IsEnabled_OnCreate()
{
@@ -73,6 +76,7 @@ public class NotificationListFormTests : BunitContext
});
}
/// <summary>Verifies that selecting the email type shows the email input and not the phone input.</summary>
[Fact]
public void SelectingEmail_ShowsEmailInput_NotPhone()
{
@@ -93,6 +97,7 @@ public class NotificationListFormTests : BunitContext
});
}
/// <summary>Verifies that selecting the SMS type shows the phone input and not the email input.</summary>
[Fact]
public void SelectingSms_ShowsPhoneInput_NotEmail()
{
@@ -111,6 +116,7 @@ public class NotificationListFormTests : BunitContext
});
}
/// <summary>Verifies that the type selector is disabled when editing an existing list.</summary>
[Fact]
public void TypeSelector_IsDisabled_OnEdit()
{
@@ -128,6 +134,7 @@ public class NotificationListFormTests : BunitContext
});
}
/// <summary>Verifies that creating an SMS list persists the SMS type.</summary>
[Fact]
public void CreatingSmsList_PersistsTypeSms()
{
@@ -150,6 +157,7 @@ public class NotificationListFormTests : BunitContext
});
}
/// <summary>Verifies that adding an SMS recipient creates an SMS recipient with its phone number set.</summary>
[Fact]
public void AddingSmsRecipient_CreatesForSmsRecipient_WithPhoneSet()
{
@@ -179,6 +187,7 @@ public class NotificationListFormTests : BunitContext
});
}
/// <summary>Verifies that adding an SMS recipient with an invalid phone number is rejected.</summary>
[Fact]
public void AddingSmsRecipient_RejectsInvalidPhone()
{
@@ -13,7 +13,7 @@ using NotificationListsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.N
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// bUnit rendering tests for the standalone Notification Lists page (Task 7).
/// bUnit rendering tests for the standalone Notification Lists page.
/// </summary>
public class NotificationListsPageTests : BunitContext
{
@@ -32,6 +32,7 @@ public class NotificationListsPageTests : BunitContext
Services.AddTestConfigurationMutations();
}
/// <summary>Verifies that the page renders a row for each notification list.</summary>
[Fact]
public void RendersNotificationListRows()
{
@@ -54,6 +55,7 @@ public class NotificationListsPageTests : BunitContext
});
}
/// <summary>Verifies that the page shows an empty state when there are no lists.</summary>
[Fact]
public void ShowsEmptyState_WhenNoLists()
{
@@ -70,6 +72,7 @@ public class NotificationListsPageTests : BunitContext
Assert.Contains("No notification lists", cut.Markup));
}
/// <summary>Verifies that deleting a list confirms, deletes it and reloads the list.</summary>
[Fact]
public void DeleteList_ConfirmsThenDeletesAndReloads()
{
@@ -100,6 +103,7 @@ public class NotificationListsPageTests : BunitContext
});
}
/// <summary>Verifies that the type column renders SMS for an SMS list.</summary>
[Fact]
public void RendersTypeColumn_Sms()
{
@@ -125,6 +129,7 @@ public class NotificationListsPageTests : BunitContext
});
}
/// <summary>Verifies that an SMS list recipient badge shows the phone number instead of empty angle brackets.</summary>
[Fact]
public void SmsListRecipientBadge_ShowsPhoneNumber_NotEmptyAngleBrackets()
{
@@ -154,6 +159,7 @@ public class NotificationListsPageTests : BunitContext
});
}
/// <summary>Verifies that the type column renders Email for an email list.</summary>
[Fact]
public void RendersTypeColumn_Email()
{
@@ -192,19 +198,28 @@ public class NotificationListsPageTests : BunitContext
/// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
private sealed class AlwaysConfirmDialogService : IDialogService
{
/// <inheritdoc />
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.
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null)
=> Task.FromResult(true);
/// <inheritdoc />
public Task<string?> PromptAsync(
string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
/// <summary>Returns a completed task with the default result without showing a dialog.</summary>
/// <typeparam name="TResult">The dialog result type.</typeparam>
/// <param name="title">The dialog title.</param>
/// <param name="body">The dialog body fragment.</param>
/// <param name="size">The optional dialog size.</param>
/// <returns>A task that resolves to the default result.</returns>
public Task<TResult?> ShowAsync<TResult>(
string title,
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
@@ -65,6 +65,7 @@ public class NotificationReportDetailModalTests : BunitContext
DeliveredAt: DateTimeOffset.UtcNow.AddHours(-2), IsStuck: false),
}, TotalCount: 2);
/// <summary>Initializes the test context, registering the fake outbox actor and dialog service.</summary>
public NotificationReportDetailModalTests()
{
_comms = new CommunicationService(
@@ -98,6 +99,7 @@ public class NotificationReportDetailModalTests : BunitContext
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
}
/// <summary>Verifies that double click row, opens detail modal.</summary>
[Fact]
public void DoubleClickRow_OpensDetailModal()
{
@@ -119,6 +121,7 @@ public class NotificationReportDetailModalTests : BunitContext
});
}
/// <summary>Verifies that modal, shows full notification id, not truncated.</summary>
[Fact]
public void Modal_ShowsFullNotificationId_NotTruncated()
{
@@ -138,6 +141,7 @@ public class NotificationReportDetailModalTests : BunitContext
});
}
/// <summary>Verifies that close button, dismisses modal.</summary>
[Fact]
public void CloseButton_DismissesModal()
{
@@ -156,6 +160,7 @@ public class NotificationReportDetailModalTests : BunitContext
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".modal.show")));
}
/// <summary>Verifies that modal, shows last error, when present.</summary>
[Fact]
public void Modal_ShowsLastError_WhenPresent()
{
@@ -173,6 +178,7 @@ public class NotificationReportDetailModalTests : BunitContext
});
}
/// <summary>Verifies that modal, fetches and shows body.</summary>
[Fact]
public void Modal_FetchesAndShowsBody()
{
@@ -192,6 +198,7 @@ public class NotificationReportDetailModalTests : BunitContext
});
}
/// <summary>Verifies that modal, shows recipients, from resolved targets.</summary>
[Fact]
public void Modal_ShowsRecipients_FromResolvedTargets()
{
@@ -210,6 +217,7 @@ public class NotificationReportDetailModalTests : BunitContext
});
}
/// <summary>Verifies that modal, shows list fallback, when resolved targets null.</summary>
[Fact]
public void Modal_ShowsListFallback_WhenResolvedTargetsNull()
{
@@ -234,6 +242,7 @@ public class NotificationReportDetailModalTests : BunitContext
});
}
/// <summary>Verifies that modal, shows error, when detail fetch fails.</summary>
[Fact]
public void Modal_ShowsError_WhenDetailFetchFails()
{
@@ -310,6 +319,7 @@ public class NotificationReportDetailModalTests : BunitContext
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".modal.show")));
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -321,6 +331,8 @@ public class NotificationReportDetailModalTests : BunitContext
private sealed class ScriptedOutboxActor : ReceiveActor
{
/// <summary>Creates the scripted outbox actor bound to the owning test class.</summary>
/// <param name="test">The owning test whose scripted responses the actor replays.</param>
public ScriptedOutboxActor(NotificationReportDetailModalTests test)
{
Receive<NotificationOutboxQueryRequest>(_ => Sender.Tell(test._queryReply));
@@ -337,19 +349,28 @@ public class NotificationReportDetailModalTests : BunitContext
private sealed class AlwaysConfirmDialogService : IDialogService
{
/// <inheritdoc />
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.
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null)
=> Task.FromResult(true);
/// <inheritdoc />
public Task<string?> PromptAsync(
string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
/// <summary>Returns the default result without showing a dialog.</summary>
/// <typeparam name="TResult">The type of the dialog result.</typeparam>
/// <param name="title">The dialog title (ignored).</param>
/// <param name="body">The dialog body (ignored).</param>
/// <param name="size">The dialog size (ignored).</param>
/// <returns>A task whose result is the default value.</returns>
public Task<TResult?> ShowAsync<TResult>(
string title,
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
@@ -54,6 +54,7 @@ public class NotificationReportPageTests : BunitContext
private readonly List<RetryNotificationRequest> _retryRequests = new();
private readonly List<DiscardNotificationRequest> _discardRequests = new();
/// <summary>Initializes the test class and registers the services the page needs.</summary>
public NotificationReportPageTests()
{
_comms = new CommunicationService(
@@ -84,12 +85,13 @@ public class NotificationReportPageTests : BunitContext
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
// CentralUI-028: the page now injects SiteScopeService — the test user
// The page now injects SiteScopeService — the test user
// has no SiteId claims, so this resolves to system-wide and the
// pre-existing test expectations hold.
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
}
/// <summary>Verifies that the page requires the Deployment policy.</summary>
[Fact]
public void Page_RequiresDeploymentPolicy()
{
@@ -102,6 +104,7 @@ public class NotificationReportPageTests : BunitContext
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
}
/// <summary>Verifies that the page renders the notification rows.</summary>
[Fact]
public void Renders_NotificationRows()
{
@@ -115,6 +118,7 @@ public class NotificationReportPageTests : BunitContext
});
}
/// <summary>Verifies that a stuck row is badged.</summary>
[Fact]
public void StuckRow_IsBadged()
{
@@ -131,6 +135,7 @@ public class NotificationReportPageTests : BunitContext
});
}
/// <summary>Verifies that clicking Retry on a parked row calls the retry notification command.</summary>
[Fact]
public void ClickRetry_OnParkedRow_CallsRetryNotification()
{
@@ -152,6 +157,7 @@ public class NotificationReportPageTests : BunitContext
});
}
/// <summary>Verifies that clicking Discard on a parked row calls the discard notification command.</summary>
[Fact]
public void ClickDiscard_OnParkedRow_CallsDiscardNotification()
{
@@ -173,6 +179,7 @@ public class NotificationReportPageTests : BunitContext
});
}
/// <summary>Verifies that a query failure shows an error message.</summary>
[Fact]
public void QueryFailure_ShowsErrorMessage()
{
@@ -187,10 +194,11 @@ public class NotificationReportPageTests : BunitContext
}
// ─────────────────────────────────────────────────────────────────────────
// Bundle D drill-in (#23 M7-T10) — every notification row carries a
// Audit drill-in — every notification row carries a
// "View audit history" link to /audit/log?correlationId={NotificationId}.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Verifies that the View audit history link on a notification row has the correct href.</summary>
[Fact]
public void NotificationRow_ViewAuditHistory_Link_HasCorrectHref()
{
@@ -220,6 +228,7 @@ public class NotificationReportPageTests : BunitContext
});
}
/// <summary>Verifies that clicking the audit history link navigates to the Audit Log with the correlation id.</summary>
[Fact]
public void Click_NavigatesTo_AuditLog_WithCorrelationId()
{
@@ -249,6 +258,7 @@ public class NotificationReportPageTests : BunitContext
// with that id comes back.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Verifies that a dashed GUID in the correlation id query is normalised to the stored N form.</summary>
[Fact]
public void NotificationReport_CorrelationIdQuery_DashedGuid_IsNormalisedToTheStoredNForm()
{
@@ -268,6 +278,7 @@ public class NotificationReportPageTests : BunitContext
});
}
/// <summary>Verifies that the correlation id query seeds the notification filter and automatically opens the detail view.</summary>
[Fact]
public void NotificationReport_CorrelationIdQuery_SeedsTheNotificationFilterAndAutoOpensDetail()
{
@@ -303,6 +314,7 @@ public class NotificationReportPageTests : BunitContext
});
}
/// <summary>Verifies that clearing the filters drops the notification filter.</summary>
[Fact]
public void ClearFilters_DropsTheNotificationFilter()
{
@@ -326,6 +338,7 @@ public class NotificationReportPageTests : BunitContext
});
}
/// <summary>Verifies that clearing the notification filter pill drops the filter and re-queries.</summary>
[Fact]
public void NotificationFilterPill_Clear_DropsTheFilterAndRequeries()
{
@@ -346,6 +359,7 @@ public class NotificationReportPageTests : BunitContext
});
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -361,6 +375,8 @@ public class NotificationReportPageTests : BunitContext
/// </summary>
private sealed class ScriptedOutboxActor : ReceiveActor
{
/// <summary>Initializes the actor with the test that owns it.</summary>
/// <param name="test">The test.</param>
public ScriptedOutboxActor(NotificationReportPageTests test)
{
Receive<NotificationOutboxQueryRequest>(req =>
@@ -397,19 +413,28 @@ public class NotificationReportPageTests : BunitContext
/// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
private sealed class AlwaysConfirmDialogService : IDialogService
{
/// <inheritdoc />
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.
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null)
=> Task.FromResult(true);
/// <inheritdoc />
public Task<string?> PromptAsync(
string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
/// <summary>Returns the default result without showing a dialog.</summary>
/// <typeparam name="TResult">The dialog result type.</typeparam>
/// <param name="title">The dialog title.</param>
/// <param name="body">The dialog body fragment.</param>
/// <param name="size">The optional dialog size.</param>
/// <returns>A task that resolves to the default result.</returns>
public Task<TResult?> ShowAsync<TResult>(
string title,
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
@@ -66,6 +66,9 @@ public class PageScopedDetailStateTests : BunitContext
JSInterop.Mode = JSRuntimeMode.Loose;
}
/// <summary>Verifies that paging the Parked Messages list closes the open detail drawer.</summary>
/// <param name="pagingMethod">The name of the paging method to invoke.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("PrevPage")]
[InlineData("NextPage")]
@@ -94,6 +97,8 @@ public class PageScopedDetailStateTests : BunitContext
Assert.Null(GetPrivate(cut.Instance, "_drawerMessageId"));
}
/// <summary>Verifies that paging the configuration audit log closes the open state modal.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ConfigurationAuditLog_Paging_ClosesTheStateModal()
{
@@ -113,7 +118,10 @@ public class PageScopedDetailStateTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Initializes the stub with the authentication state it returns.</summary>
/// <param name="state">The authentication state to return.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
@@ -31,7 +31,7 @@ using TransportImportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Des
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// CentralUI-033: tests for the drill-in / query-string code paths on the two
/// Tests for the drill-in / query-string code paths on the two
/// newest pages (TransportImport + SiteCallsReport). The base happy-path cases
/// (Parked, stuck=true, no params) live next to the rest of the page's tests in
/// <c>SiteCallsReportPageTests</c> / <c>TransportImportPageTests</c>; this file
@@ -40,12 +40,12 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// </summary>
public sealed class QueryStringDrillInTests
{
// STM: CentralUI-033-QueryStringDrillIn marker — used by grep verification.
// -----------------------------------------------------------------
// SiteCallsReport — ?status=
// -----------------------------------------------------------------
/// <summary>Verifies that a case-insensitive status query parameter on the site calls report normalises to the canonical casing.</summary>
[Fact]
public void SiteCallsReport_StatusParam_CaseInsensitiveMatch_NormalisesToCanonicalCasing()
{
@@ -68,6 +68,7 @@ public sealed class QueryStringDrillInTests
});
}
/// <summary>Verifies that an unrecognised status query parameter on the site calls report is silently dropped.</summary>
[Fact]
public void SiteCallsReport_StatusParam_Unrecognised_IsSilentlyDropped()
{
@@ -88,6 +89,7 @@ public sealed class QueryStringDrillInTests
});
}
/// <summary>Verifies that a non-boolean stuck query parameter on the site calls report is silently dropped.</summary>
[Fact]
public void SiteCallsReport_StuckParam_NonBoolean_IsSilentlyDropped()
{
@@ -110,6 +112,7 @@ public sealed class QueryStringDrillInTests
// SiteCallsReport — ?correlationId= (Flow Debugger / Audit Log deep link, D2)
// -----------------------------------------------------------------
/// <summary>Verifies that the correlation id query parameter seeds the operation filter on the site calls report.</summary>
[Fact]
public void SiteCallsReport_CorrelationIdQuery_SeedsTheOperationFilter()
{
@@ -131,6 +134,7 @@ public sealed class QueryStringDrillInTests
});
}
/// <summary>Verifies that a malformed correlation id query parameter on the site calls report is ignored.</summary>
[Fact]
public void SiteCallsReport_CorrelationIdQuery_Malformed_IsIgnored()
{
@@ -155,6 +159,7 @@ public sealed class QueryStringDrillInTests
// NotificationReport — ?correlationId= (Flow Debugger follow-ups, D2)
// -----------------------------------------------------------------
/// <summary>Verifies that a blank correlation id query parameter on the notification report is ignored.</summary>
[Fact]
public void NotificationReport_CorrelationIdQuery_Blank_IsIgnored()
{
@@ -180,7 +185,7 @@ public sealed class QueryStringDrillInTests
// -----------------------------------------------------------------
/// <summary>
/// CentralUI-033: TransportImport.razor declares no <c>[Parameter]</c> /
/// TransportImport.razor declares no <c>[Parameter]</c> /
/// <c>SupplyParameterFromQuery</c> bindings — the wizard's initial state is
/// purely <c>ImportWizardStep.Upload</c> regardless of the query-string. This
/// test pins that contract: navigating with an unrecognised query-string
@@ -204,7 +209,7 @@ public sealed class QueryStringDrillInTests
.GetValue(cut.Instance)!;
Assert.Equal(TransportImportPage.ImportWizardStep.Upload, step);
// And the Step-1 InputFile control is rendered — the page came up clean.
// And the first-step InputFile control is rendered — the page came up clean.
Assert.NotNull(cut.Find("input[type='file']"));
}
@@ -222,6 +227,7 @@ public sealed class QueryStringDrillInTests
public readonly CommunicationService Comms;
public readonly List<SiteCallQueryRequest> QueryRequests = new();
/// <summary>Initializes a new instance of the <c>SiteCallsReportFixture</c> class.</summary>
public SiteCallsReportFixture()
{
Comms = new CommunicationService(
@@ -233,7 +239,7 @@ public sealed class QueryStringDrillInTests
Services.AddSingleton(Comms);
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
// M6 (K14): the SiteCallsReport page now injects IKpiHistoryQueryService
// The SiteCallsReport page now injects IKpiHistoryQueryService
// for the Trends section. Provide a stand-in so these drill-in tests render.
Services.AddSingleton(Substitute.For<IKpiHistoryQueryService>());
@@ -257,6 +263,7 @@ public sealed class QueryStringDrillInTests
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -269,6 +276,8 @@ public sealed class QueryStringDrillInTests
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
{
/// <summary>Initializes a new instance of the <c>ScriptedSiteCallAuditActor</c> class.</summary>
/// <param name="fixture">The fixture supplying the scripted replies.</param>
public ScriptedSiteCallAuditActor(SiteCallsReportFixture fixture)
{
Receive<SiteCallQueryRequest>(req =>
@@ -287,6 +296,7 @@ public sealed class QueryStringDrillInTests
public readonly CommunicationService Comms;
public readonly List<NotificationOutboxQueryRequest> QueryRequests = new();
/// <summary>Initializes a new instance of the <c>NotificationReportFixture</c> class.</summary>
public NotificationReportFixture()
{
Comms = new CommunicationService(
@@ -319,6 +329,7 @@ public sealed class QueryStringDrillInTests
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -331,6 +342,8 @@ public sealed class QueryStringDrillInTests
private sealed class ScriptedNotificationOutboxActor : ReceiveActor
{
/// <summary>Initializes a new instance of the <c>ScriptedNotificationOutboxActor</c> class.</summary>
/// <param name="fixture">The fixture supplying the scripted replies.</param>
public ScriptedNotificationOutboxActor(NotificationReportFixture fixture)
{
Receive<NotificationOutboxQueryRequest>(req =>
@@ -345,17 +358,26 @@ public sealed class QueryStringDrillInTests
private sealed class AlwaysConfirmDialogService : IDialogService
{
/// <inheritdoc />
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.
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null)
=> Task.FromResult(true);
/// <inheritdoc />
public Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
/// <summary>Returns the default result without showing a dialog.</summary>
/// <typeparam name="TResult">The dialog result type.</typeparam>
/// <param name="title">The dialog title.</param>
/// <param name="body">The dialog body fragment.</param>
/// <param name="size">The optional dialog size.</param>
/// <returns>A task that resolves to the default result.</returns>
public Task<TResult?> ShowAsync<TResult>(
string title,
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
@@ -365,6 +387,7 @@ public sealed class QueryStringDrillInTests
private sealed class TransportImportFixture : BunitContext
{
/// <summary>Initializes a new instance of the <c>TransportImportFixture</c> class.</summary>
public TransportImportFixture()
{
JSInterop.Mode = JSRuntimeMode.Loose;
@@ -372,7 +395,7 @@ public sealed class QueryStringDrillInTests
var importer = Substitute.For<IBundleImporter>();
Services.AddSingleton(importer);
Services.AddSingleton(Substitute.For<IAuditService>());
// M8 E2: TransportImport's Map step injects ISiteRepository.
// TransportImport's Map step injects ISiteRepository.
Services.AddSingleton(Substitute.For<ISiteRepository>());
// Register row 44: the wizard reads the circuit's client IP for the
// per-IP unlock throttle. No HTTP context under bUnit — use the seam.
@@ -22,7 +22,7 @@ using SiteCallsReportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Sit
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// bUnit rendering tests for the Site Calls report page (Site Call Audit #22).
/// bUnit rendering tests for the Site Calls report page.
///
/// Testability note: <see cref="CommunicationService"/> is a concrete class with
/// non-virtual methods, so NSubstitute cannot intercept it. The page's calls all
@@ -82,6 +82,7 @@ public class SiteCallsReportPageTests : BunitContext
private DiscardSiteCallResponse _discardReply =
new("q", SiteCallRelayOutcome.Applied, true, true, null);
/// <summary>Initializes the bUnit context with the fakes and services the Site Calls report page depends on.</summary>
public SiteCallsReportPageTests()
{
_comms = new CommunicationService(
@@ -129,6 +130,7 @@ public class SiteCallsReportPageTests : BunitContext
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
}
/// <summary>Verifies that the page requires the Deployment authorization policy.</summary>
[Fact]
public void Page_RequiresDeploymentPolicy()
{
@@ -141,6 +143,7 @@ public class SiteCallsReportPageTests : BunitContext
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
}
/// <summary>Verifies that the page renders a row for each site call returned by the query.</summary>
[Fact]
public void Renders_SiteCallRows()
{
@@ -153,6 +156,7 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <summary>Verifies that a stuck row is shown with a stuck badge.</summary>
[Fact]
public void StuckRow_IsBadged()
{
@@ -167,6 +171,7 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <summary>Verifies that the Retry and Discard buttons are shown only on parked rows.</summary>
[Fact]
public void RetryDiscardButtons_ShownOnlyOnParkedRows()
{
@@ -192,6 +197,7 @@ public class SiteCallsReportPageTests : BunitContext
b => b.TextContent.Contains("Discard"));
}
/// <summary>Verifies that clicking Retry on a parked row relays the retry to the owning site.</summary>
[Fact]
public void ClickRetry_OnParkedRow_RelaysRetryToOwningSite()
{
@@ -215,6 +221,7 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <summary>Verifies that clicking Discard on a parked row relays the discard to the owning site.</summary>
[Fact]
public void ClickDiscard_OnParkedRow_RelaysDiscardToOwningSite()
{
@@ -237,6 +244,7 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <summary>Verifies that a retry relay to an unreachable site shows a distinct message.</summary>
[Fact]
public void RetryRelay_SiteUnreachable_ShowsDistinctMessage()
{
@@ -259,6 +267,7 @@ public class SiteCallsReportPageTests : BunitContext
Assert.Contains("offline", cut.Markup));
}
/// <summary>Verifies that a failed query shows an error message.</summary>
[Fact]
public void QueryFailure_ShowsErrorMessage()
{
@@ -277,6 +286,7 @@ public class SiteCallsReportPageTests : BunitContext
// /audit/log?correlationId={TrackedOperationId}.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Verifies that the View Audit History link on a row has the correct href.</summary>
[Fact]
public void SiteCallRow_ViewAuditHistory_Link_HasCorrectHref()
{
@@ -310,6 +320,7 @@ public class SiteCallsReportPageTests : BunitContext
// page numbers; the request echoes the cursor back to the actor.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Verifies that the Next button is hidden when there is no further page.</summary>
[Fact]
public void Paging_NextButton_HiddenWhenNoFurtherPage()
{
@@ -324,6 +335,7 @@ public class SiteCallsReportPageTests : BunitContext
Assert.True(prev.HasAttribute("disabled"));
}
/// <summary>Verifies that the Next button advances using the keyset cursor.</summary>
[Fact]
public void Paging_NextButton_AdvancesUsingKeysetCursor()
{
@@ -364,6 +376,7 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <summary>Verifies that the Previous button pops the back stack and refetches using the prior cursor.</summary>
[Fact]
public void Paging_PrevButton_PopsBackStackAndRefetchesPriorCursor()
{
@@ -407,7 +420,7 @@ public class SiteCallsReportPageTests : BunitContext
cut.WaitForAssertion(() =>
{
// Query 3 is the Previous refetch — the back-stack popped the page-1
// 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);
@@ -417,6 +430,7 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <summary>Verifies that retrying a row that is no longer parked shows an info message and exactly one toast.</summary>
[Fact]
public void RetryRelay_NotParked_ShowsInfoMessage_AndExactlyOneToast()
{
@@ -453,6 +467,7 @@ public class SiteCallsReportPageTests : BunitContext
// is already filtered, and the filter card controls must reflect the values.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Verifies that navigating with the parked status query parameter loads the grid pre-filtered to parked rows.</summary>
[Fact]
public void NavigateWithStatusParkedParam_LoadsGridPreFilteredToParked()
{
@@ -476,6 +491,7 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <summary>Verifies that navigating with the stuck query parameter set to true loads the grid with the stuck filter applied.</summary>
[Fact]
public void NavigateWithStuckTrueParam_LoadsGridWithStuckFilterApplied()
{
@@ -497,6 +513,7 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <summary>Verifies that navigating without query parameters loads the grid unfiltered.</summary>
[Fact]
public void NavigateWithNoQueryParams_LoadsGridUnfiltered()
{
@@ -521,9 +538,10 @@ public class SiteCallsReportPageTests : BunitContext
// Flow Debugger / Audit Log deep link: ?correlationId=<TrackedOperationId>
// seeds a read-only "Operation" pill filter, rides on the query as
// TrackedOperationIdFilter, and auto-opens the detail modal when exactly
// one matching row comes back (follow-ups plan D2 / Task 4).
// one matching row comes back.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Verifies that a correlation ID query parameter seeds the operation filter and automatically opens the detail view.</summary>
[Fact]
public void SiteCallsReport_CorrelationIdQuery_SeedsTheOperationFilterAndAutoOpensDetail()
{
@@ -558,6 +576,7 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <summary>Verifies that a correlation ID query parameter with no matching row does not open the detail view.</summary>
[Fact]
public void SiteCallsReport_CorrelationIdQuery_NoMatchingRow_DoesNotOpenDetail()
{
@@ -580,6 +599,7 @@ public class SiteCallsReportPageTests : BunitContext
Assert.Empty(_detailRequests);
}
/// <summary>Verifies that clearing the filters drops the operation filter.</summary>
[Fact]
public void ClearFilters_DropsTheOperationFilter()
{
@@ -597,6 +617,7 @@ public class SiteCallsReportPageTests : BunitContext
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll("[data-test='site-calls-filter-operation']")));
}
/// <summary>Verifies that the operation pill's clear button drops the filter and requeries.</summary>
[Fact]
public void OperationPill_ClearButton_DropsTheFilterAndRequeries()
{
@@ -616,10 +637,11 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <summary>Verifies that a Deployment user scoped to one site does not see rows from out-of-scope sites.</summary>
[Fact]
public void SiteScoping_ScopedDeploymentUser_HidesOutOfScopeRows()
{
// CentralUI-028: a Deployment user scoped to Plant A only must not see
// 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[]
@@ -647,6 +669,7 @@ public class SiteCallsReportPageTests : BunitContext
// section is collapsed on init; expanding it lazy-loads the series.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Verifies that the trends section container always renders.</summary>
[Fact]
public void TrendsSection_Container_AlwaysRenders()
{
@@ -658,6 +681,7 @@ public class SiteCallsReportPageTests : BunitContext
Assert.NotNull(cut.Find("[data-test='site-calls-trends']")));
}
/// <summary>Verifies that an expanded trends section renders charts from the known series.</summary>
[Fact]
public void TrendsSection_WhenExpanded_RendersChartsFromKnownSeries()
{
@@ -680,6 +704,7 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <summary>Verifies that toggling the trends window reloads the series.</summary>
[Fact]
public void TrendsSection_WindowToggle_ReloadsSeries()
{
@@ -700,10 +725,11 @@ public class SiteCallsReportPageTests : BunitContext
Assert.True(_kpiHistory.ReceivedCalls().Count() > afterExpand));
}
/// <summary>Verifies that the long-range trend windows render and reload the series.</summary>
[Fact]
public void TrendsSection_LongRangeWindows_RenderAndReloadSeries()
{
// The 30d/90d buttons (plan #22 T7) render alongside 24h/7d and clicking
// The 30d/90d buttons 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>();
@@ -725,6 +751,7 @@ public class SiteCallsReportPageTests : BunitContext
Assert.True(_kpiHistory.ReceivedCalls().Count() > afterExpand));
}
/// <summary>Verifies that the page still renders when the KPI history query throws.</summary>
[Fact]
public void TrendsSection_WhenKpiHistoryThrows_PageStillRenders()
{
@@ -751,6 +778,7 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -766,6 +794,8 @@ public class SiteCallsReportPageTests : BunitContext
/// </summary>
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
{
/// <summary>Creates the stand-in actor, wiring each request type to the owning test's recorded requests and scripted replies.</summary>
/// <param name="test">The owning test whose recorded requests and scripted replies the actor uses.</param>
public ScriptedSiteCallAuditActor(SiteCallsReportPageTests test)
{
Receive<SiteCallQueryRequest>(r =>
@@ -794,19 +824,28 @@ public class SiteCallsReportPageTests : BunitContext
/// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
private sealed class AlwaysConfirmDialogService : IDialogService
{
/// <inheritdoc />
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.
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null)
=> Task.FromResult(true);
/// <inheritdoc />
public Task<string?> PromptAsync(
string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
/// <summary>Returns a null result without rendering the dialog.</summary>
/// <typeparam name="TResult">The dialog result type.</typeparam>
/// <param name="title">The dialog title, ignored.</param>
/// <param name="body">The dialog body fragment, ignored.</param>
/// <param name="size">The dialog size, ignored.</param>
/// <returns>A task that completes with a null result.</returns>
public Task<TResult?> ShowAsync<TResult>(
string title,
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
@@ -52,6 +52,7 @@ public class SmsConfigurationPageTests : BunitContext
return repo;
}
/// <summary>Verifies that the read-only view shows the config row but never renders the auth token value.</summary>
[Fact]
public void ReadOnlyView_ShowsConfigRow_ButNeverRendersAuthTokenValue()
{
@@ -74,6 +75,7 @@ public class SmsConfigurationPageTests : BunitContext
});
}
/// <summary>Verifies that the edit form does not prefill the auth token but does prefill the messaging service SID.</summary>
[Fact]
public void EditForm_DoesNotPrefillAuthToken_ButPrefillsMessagingServiceSid()
{
@@ -96,6 +98,7 @@ public class SmsConfigurationPageTests : BunitContext
});
}
/// <summary>Verifies that saving a new configuration calls Add and SaveChanges.</summary>
[Fact]
public void SavingNewConfig_CallsAddAndSaveChanges()
{
@@ -127,10 +130,11 @@ public class SmsConfigurationPageTests : BunitContext
});
}
/// <summary>Verifies that a new configuration with only a messaging service SID and no from number saves successfully.</summary>
[Fact]
public void SavingNewConfig_MessagingServiceSidOnly_NoFromNumber_Saves()
{
// UI-Med-2: a Twilio Messaging-Service-only config is valid with no From number.
// A Twilio Messaging-Service-only config is valid with no From number.
// The either-or validation must accept it and persist a null FromNumber.
var repo = RepoWith();
Services.AddSingleton(repo);
@@ -160,6 +164,7 @@ public class SmsConfigurationPageTests : BunitContext
});
}
/// <summary>Verifies that saving an edit with a blank auth token preserves the existing token.</summary>
[Fact]
public void SavingEdit_WithBlankAuthToken_PreservesExistingToken()
{
@@ -185,6 +190,7 @@ public class SmsConfigurationPageTests : BunitContext
});
}
/// <summary>Verifies that saving an edit with a new auth token overwrites the token.</summary>
[Fact]
public void SavingEdit_WithNewAuthToken_OverwritesToken()
{

Some files were not shown because too many files have changed in this diff Show More