diff --git a/docs/requirements/Component-CentralUI.md b/docs/requirements/Component-CentralUI.md index fdafd323..8fe002eb 100644 --- a/docs/requirements/Component-CentralUI.md +++ b/docs/requirements/Component-CentralUI.md @@ -13,6 +13,7 @@ Central cluster only. Sites have no user interface. - **Framework**: Blazor Server (ASP.NET Core). UI logic executes on the server, updates pushed to the browser via SignalR. - Keeps the entire stack in C#/.NET, consistent with the rest of the system (Akka.NET, EF Core). - SignalR provides built-in support for real-time UI updates. +- **Dialog service confirm labels.** Every confirmation goes through `IDialogService.ConfirmAsync(title, message, danger, confirmLabel)`, rendered by the single `DialogHost` in `MainLayout`. `danger` selects **styling only** (`btn-danger`); `confirmLabel` names the action. When no label is supplied the button falls back to "Delete" for a danger confirm and "Confirm" otherwise — a convenience for the ~20 genuine delete call sites, and the reason the Health dashboard's *Trigger failover* dialog used to offer a red **Delete** button (deferred-work register row 28). **Any destructive action that is not literally a delete must pass `confirmLabel`**: failover → *Fail over*, secured-write approve/reject → *Approve* / *Reject*, instance disable → *Disable*, and every parked-message / notification / cached-call discard → *Discard*. ## Failover Behavior @@ -243,6 +244,7 @@ Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms - Headline **Notification Outbox KPI tiles** — queue depth, stuck count, and parked count. These are central-computed by the Notification Outbox from the central `Notifications` table (not part of any site health report). The full outbox view is on the dedicated Notification Outbox page. - Headline **Site Call Audit KPI tiles** — buffered count, parked count, and failed-last-interval. These are central-computed by the Site Call Audit component from the central `SiteCalls` table (not part of any site health report). The full cached-call view is on the dedicated Site Calls page. - Headline **Audit KPI tiles** — three tiles in a new "Audit" KPI group: **Audit volume**, **Audit error rate**, and **Audit backlog**. These are sourced from the Audit Log component (#23) and Health Monitoring per the metric definitions in Component-HealthMonitoring.md; the dashboard simply surfaces them. The full audit query view is on the dedicated Audit Log page. +- **Trigger failover** (`CentralFailoverControl`, Administrator only) — on the central card and on each site card. Disabled with an explanatory tooltip when there is no standby to take over. It always confirms first, and the confirmation's red button reads **Fail over** (see the dialog-label contract under Technology); the central variant's copy states that this page is served by the node being restarted and will disconnect and reconnect, while the site variant deliberately does not, since a site is a different cluster. - **Shared KPI cache (arch-review WP2.4).** All four KPI families above — Notification Outbox, Site Call Audit, Audit, and their per-site/per-node breakdowns — are read through a process-level `IKpiSnapshotCache`, not queried per page load. Each accessor is independently memoized (8s TTL) with single-flight production, so N Blazor circuits polling the same KPI inside one window (e.g. ten operators with the Health dashboard open) cost the node ONE aggregate SQL round trip per KPI per window, not N. A failed query is never memoized — the next caller re-attempts it, and the calling page's existing per-tile "unavailable" degradation is unchanged. Every KPI-tile page (Health, Notification Outbox, Site Calls, Audit Log) shares the same cache instance. ### Site Event Log Viewer (Deployment Role) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/CentralFailoverControl.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/CentralFailoverControl.razor index 9bb751ff..33c16242 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/CentralFailoverControl.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/CentralFailoverControl.razor @@ -70,14 +70,16 @@ + "standby takes over and its Deployment Manager singleton hands over gracefully. " + "In-flight work on that site node is interrupted, and the site is briefly " + "unavailable while the handover completes.", - danger: true) + danger: true, + confirmLabel: "Fail over") : await Dialog.ConfirmAsync( "Trigger central failover?", "The active central node will leave the cluster and restart; the standby takes over " + "and becomes active. Cluster singletons hand over gracefully, but in-flight work on " + "the active node is interrupted. This page is served by the active node, so it will " + "briefly disconnect and reconnect against the new active node.", - danger: true); + danger: true, + confirmLabel: "Fail over"); if (!confirmed) { diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor index 4fef9a50..22dcd817 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor @@ -868,7 +868,8 @@ var confirmed = await Dialog.ConfirmAsync( "Disable Instance", $"Disable instance '{inst.UniqueName}'? The instance actor will be stopped.", - danger: true); + danger: true, + confirmLabel: "Disable"); if (!confirmed) return; _actionInProgress = true; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor index df38904a..d9164280 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor @@ -668,7 +668,8 @@ var confirmed = await Dialog.ConfirmAsync( "Discard parked messages", $"Permanently discard {ids.Count} message{(ids.Count == 1 ? "" : "s")}? This cannot be undone.", - danger: true); + danger: true, + confirmLabel: "Discard"); if (!confirmed) return; _bulkInProgress = true; @@ -720,7 +721,8 @@ var confirmed = await Dialog.ConfirmAsync( "Discard parked message", $"Permanently discard message {ShortId(msg.MessageId)}? This cannot be undone.", - danger: true); + danger: true, + confirmLabel: "Discard"); if (!confirmed) return false; _actionInProgress = true; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationReport.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationReport.razor index 69303273..5af9ffb2 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationReport.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationReport.razor @@ -566,7 +566,8 @@ var confirmed = await Dialog.ConfirmAsync( "Discard notification", $"Permanently discard notification {ShortId(n.NotificationId)} (\"{n.Subject}\")? This cannot be undone.", - danger: true); + danger: true, + confirmLabel: "Discard"); if (!confirmed) return; _actionInProgress = true; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor index db1b5f11..cbe873e3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor @@ -424,7 +424,8 @@ $"Value: {row.ValueJson} ({row.ValueType})\n" + $"Submitted {FormatAge(row.SubmittedAtUtc)} ago ({row.SubmittedAtUtc:u})\n" + $"Operator: {row.OperatorUser}", - danger: true); + danger: true, + confirmLabel: "Approve"); if (!confirmed) return; _actionInProgress = true; @@ -460,7 +461,8 @@ var confirmed = await Dialog.ConfirmAsync( "Reject secured write", $"Reject the secured write to {row.SiteId}/{row.ConnectionName}/{row.TagPath}?", - danger: true); + danger: true, + confirmLabel: "Reject"); if (!confirmed) return; _actionInProgress = true; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs index c501baaa..72d34fdc 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs @@ -343,7 +343,8 @@ public partial class SiteCallsReport "Discard cached call", $"Relay a discard of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " + $"to site {SiteName(c.SourceSite)}? This cannot be undone.", - danger: true); + danger: true, + confirmLabel: "Discard"); if (!confirmed) return; _actionInProgress = true; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogHost.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogHost.razor index 51efbb36..22bac228 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogHost.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogHost.razor @@ -185,9 +185,17 @@ svc.Resolve(resolveValue); } - private static string ConfirmLabel(DialogState state) => state.Kind switch - { - DialogKind.Prompt => "Save", - _ => state.Danger ? "Delete" : "Confirm", - }; + // An explicit label from the caller always wins. The kind/danger fallback below + // is a convenience for the common cases only: a bare danger confirm is assumed to + // be a delete, which is exactly the assumption that put "Delete" on the Trigger + // failover button (deferred-work register row 28). Callers whose destructive verb + // is not "Delete" must pass ConfirmLabel — see IDialogService.ConfirmAsync. + private static string ConfirmLabel(DialogState state) => + !string.IsNullOrWhiteSpace(state.ConfirmLabel) + ? state.ConfirmLabel + : state.Kind switch + { + DialogKind.Prompt => "Save", + _ => state.Danger ? "Delete" : "Confirm", + }; } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogService.cs index 134c06d3..3baa15e3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogService.cs @@ -35,12 +35,19 @@ public class DialogService : IDialogService private TaskCompletionSource? _tcs; /// - public Task ConfirmAsync(string title, string message, bool danger = false) + public Task ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) { EnsureNoActiveDialog(); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _tcs = tcs; - Current = new DialogState(title, DialogKind.Confirm, message, danger, PromptInitial: string.Empty, Placeholder: null); + Current = new DialogState( + title, + DialogKind.Confirm, + message, + danger, + PromptInitial: string.Empty, + Placeholder: null, + ConfirmLabel: confirmLabel); OnChange?.Invoke(); return Project(tcs.Task, static r => r is bool b && b); } @@ -146,6 +153,9 @@ public class DialogService : IDialogService /// render inside .modal-body; null for confirm/prompt. /// Optional Bootstrap modal-dialog size modifier class applied /// to the modal-dialog element (e.g. modal-lg); null = default. +/// Explicit verb for the confirm button. null or +/// blank falls back to the kind/danger default ("Save" for a prompt, "Delete" for a +/// danger confirm, "Confirm" otherwise). public record DialogState( string Title, DialogKind Kind, @@ -154,7 +164,8 @@ public record DialogState( string PromptInitial, string? Placeholder, RenderFragment? Content = null, - string? Size = null); + string? Size = null, + string? ConfirmLabel = null); public enum DialogKind { diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/IDialogService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/IDialogService.cs index e174ed0f..0b90d48e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/IDialogService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/IDialogService.cs @@ -19,10 +19,17 @@ public interface IDialogService /// Modal title text. /// Body text shown to the user. /// When true, the confirm button renders in - /// btn-danger styling with the label "Delete"; otherwise a primary - /// "Confirm" button is shown. + /// btn-danger styling; otherwise a primary button is shown. This + /// controls styling only — it does not name the action. + /// Verb rendered on the confirm button. When + /// null or blank the button falls back to "Delete" for a danger + /// dialog and "Confirm" otherwise. Any destructive action that is + /// not literally a delete must pass this — otherwise the red button + /// on, say, a failover or a discard reads "Delete", which is simply wrong + /// (deferred-work register row 28). See the label table in + /// Component-CentralUI.md → Dialog Service. /// A task that resolves to true when the user confirms, or false when cancelled. - Task ConfirmAsync(string title, string message, bool danger = false); + Task ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null); /// /// Shows a single-line text prompt and resolves to the entered value, or diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWritesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWritesTests.cs index a1495c5d..d57ba929 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWritesTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWritesTests.cs @@ -45,7 +45,7 @@ public class SecuredWritesTests : BunitContext Services.AddSingleton(_browse); // Confirm dialogs resolve to "yes" without a DialogHost in the render scope. - _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(true)); // Empty list by default; individual tests seed the queue/history. @@ -303,7 +303,8 @@ public class SecuredWritesTests : BunitContext _dialog.Received(1).ConfirmAsync( Arg.Any(), Arg.Is(m => Regex.IsMatch(m, "Submitted .* ago")), - Arg.Any()); + Arg.Any(), + Arg.Any()); } [Fact] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/SchemaLibraryPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/SchemaLibraryPageTests.cs index 48ea76bf..c9182cf0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/SchemaLibraryPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/SchemaLibraryPageTests.cs @@ -210,7 +210,7 @@ public class SchemaLibraryPageTests : BunitContext /// A dialog service that auto-confirms, so the delete path runs end-to-end. private sealed class AlwaysConfirmDialogService : IDialogService { - public Task ConfirmAsync(string title, string message, bool danger = false) + public Task ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) => Task.FromResult(true); public Task PromptAsync( diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/FailoverConfirmLabelTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/FailoverConfirmLabelTests.cs new file mode 100644 index 00000000..37bb4e99 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/FailoverConfirmLabelTests.cs @@ -0,0 +1,138 @@ +using System.Security.Claims; +using Bunit; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health; +using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; +using ZB.MOM.WW.ScadaBridge.CentralUI.Services; +using ZB.MOM.WW.ScadaBridge.Security; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring; + +/// +/// Pins the verb on the "Trigger failover" confirmation button (deferred-work +/// register row 28). The dialog copy was always right, but the red confirm button +/// read "Delete": DialogHost assumed every danger: true +/// confirm was a delete, and no caller could say otherwise. These tests run the +/// real behind a real DialogHost — not a +/// substitute — so they cover both halves of the fix: the call site passing +/// confirmLabel, and the host preferring it over its own default. A +/// substitute-based test would pin the argument but not what the user reads. +/// +/// bUnit cannot run real JS interop, so the host's sbDialog.* focus-trap +/// calls are exercised in mode as no-ops. +/// +public class FailoverConfirmLabelTests : BunitContext +{ + private readonly IManualFailoverService _failover = Substitute.For(); + private readonly DialogService _dialog = new(); + + public FailoverConfirmLabelTests() + { + var claims = new[] + { + new Claim(JwtTokenService.UsernameClaimType, "tester"), + new Claim(JwtTokenService.RoleClaimType, "Administrator") + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + Services.AddSingleton(new TestAuthStateProvider(user)); + Services.AddAuthorizationCore(); + AuthorizationPolicies.AddScadaBridgeAuthorization(Services); + // BunitContext pre-registers a placeholder IAuthorizationService that throws when + // AuthorizeView evaluates a policy; force the real one so the admin gate is real. + Services.AddSingleton(); + Services.AddSingleton(_failover); + // The concrete service registered as the interface, so the control and the + // host share one instance (DialogHost injects IDialogService and casts). + Services.AddSingleton(_dialog); + JSInterop.Mode = JSRuntimeMode.Loose; + } + + /// + /// Renders the failover control and a live DialogHost side by side under + /// the cascading auth state AuthorizeView needs, mirroring how + /// MainLayout hosts the single shared dialog. + /// + private IRenderedComponent RenderControlWithHost(string? siteId) + { + return Render(parameters => parameters + .Add(p => p.ChildContent, (RenderFragment)(builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(CentralFailoverControl.OnlineCentralNodeCount), 2); + if (siteId is not null) + { + builder.AddAttribute(2, nameof(CentralFailoverControl.SiteId), siteId); + } + + builder.CloseComponent(); + builder.OpenComponent(3); + builder.CloseComponent(); + }))); + } + + /// + /// Opens the failover dialog and returns the footer buttons. + /// + /// + /// The click is deliberately NOT awaited. With a real + /// the control's handler parks on await ConfirmAsync(...) until someone + /// answers the dialog, so awaiting the click would deadlock the test. The dialog + /// state is published synchronously before ConfirmAsync returns its task, so + /// the modal is rendered by the time the click dispatch returns. + /// + private IReadOnlyList OpenFailoverDialog(string? siteId) + { + var host = RenderControlWithHost(siteId); + var trigger = host.FindAll("button") + .First(b => b.TextContent.Contains("failover", StringComparison.OrdinalIgnoreCase)); + + _ = trigger.ClickAsync(new()); + host.WaitForState(() => host.FindAll("div.modal-footer button").Count > 0); + + return host.FindAll("div.modal-footer button").ToList(); + } + + [Theory] + [InlineData(null)] // central pair + [InlineData("SiteA")] // site pair + public void Failover_confirm_button_says_fail_over_not_delete(string? siteId) + { + var footerButtons = OpenFailoverDialog(siteId).Select(b => b.TextContent.Trim()).ToList(); + + Assert.Contains("Fail over", footerButtons); + // The whole point of the row: the destructive verb must not be "Delete". + Assert.DoesNotContain("Delete", footerButtons); + // Cancel is still the way out — the fix must not have eaten the footer. + Assert.Contains("Cancel", footerButtons); + } + + [Fact] + public void Failover_confirm_button_keeps_destructive_styling() + { + // The label and the styling are separate concerns now (danger = red, + // confirmLabel = the verb). Relabelling must not have downgraded the + // button to a benign primary one. + var confirm = OpenFailoverDialog(siteId: null).First(b => b.TextContent.Trim() == "Fail over"); + + Assert.Contains("btn-danger", confirm.ClassList); + } + + [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. + var cut = Render(); + + await cut.InvokeAsync(() => { _ = _dialog.ConfirmAsync("Delete thing", "Sure?", danger: true); }); + cut.Render(); + + var footerButtons = cut.FindAll("div.modal-footer button").Select(b => b.TextContent.Trim()).ToList(); + Assert.Contains("Delete", footerButtons); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/HealthFailoverButtonTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/HealthFailoverButtonTests.cs index 138f5151..c3e3a752 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/HealthFailoverButtonTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/HealthFailoverButtonTests.cs @@ -99,7 +99,7 @@ public class HealthFailoverButtonTests : BunitContext public async Task Confirming_triggers_the_failover_exactly_once() { Arrange("Administrator"); - _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); + _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); _failover.FailOverCentralAsync(Arg.Any()) .Returns(Task.FromResult("akka.tcp://scadabridge@central-a:8081")); @@ -113,7 +113,7 @@ public class HealthFailoverButtonTests : BunitContext public async Task Cancelling_the_confirmation_does_not_trigger_a_failover() { Arrange("Administrator"); - _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(false); + _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(false); var cut = Render(onlineNodes: 2); await cut.Find("button").ClickAsync(new()); @@ -128,7 +128,7 @@ public class HealthFailoverButtonTests : BunitContext // the admin's own circuit drops. If the dialog does not say so, a working failover // looks like a crash they caused. Arrange("Administrator"); - _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); + _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); var cut = Render(onlineNodes: 2); await cut.Find("button").ClickAsync(new()); @@ -136,7 +136,8 @@ public class HealthFailoverButtonTests : BunitContext await _dialog.Received(1).ConfirmAsync( Arg.Any(), Arg.Is(m => m.Contains("reconnect", StringComparison.OrdinalIgnoreCase)), - Arg.Any()); + Arg.Any(), + Arg.Any()); } [Fact] @@ -145,7 +146,7 @@ public class HealthFailoverButtonTests : BunitContext // The service returns null when the peer guard refuses. The UI must not report a // failover that never happened. Arrange("Administrator"); - _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); + _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); _failover.FailOverCentralAsync(Arg.Any()).Returns(Task.FromResult(null)); var cut = Render(onlineNodes: 2); @@ -177,7 +178,7 @@ public class HealthFailoverButtonTests : BunitContext public async Task Site_card_confirming_triggers_the_site_failover_path() { Arrange("Administrator"); - _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); + _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); _failover.FailOverSiteAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new SiteFailoverOutcome(true, "akka.tcp://scadabridge@site-a-a:8082", null))); @@ -194,7 +195,7 @@ public class HealthFailoverButtonTests : BunitContext // Central failover drops the admin's circuit; a SITE failover does not, and saying // otherwise would train operators to distrust the warning that does matter. Arrange("Administrator"); - _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); + _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); _failover.FailOverSiteAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new SiteFailoverOutcome(true, "addr", null))); @@ -204,14 +205,15 @@ public class HealthFailoverButtonTests : BunitContext await _dialog.Received(1).ConfirmAsync( Arg.Any(), Arg.Is(m => !m.Contains("reconnect", StringComparison.OrdinalIgnoreCase)), - Arg.Any()); + Arg.Any(), + Arg.Any()); } [Fact] public async Task Site_card_surfaces_a_refusal_from_the_site() { Arrange("Administrator"); - _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); + _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); _failover.FailOverSiteAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new SiteFailoverOutcome( false, null, "No standby available — failing over a lone node would be an outage."))); @@ -228,7 +230,7 @@ public class HealthFailoverButtonTests : BunitContext // A timeout is not a "no" from the site — the operator must be able to tell the // difference, because the failover may or may not have taken effect. Arrange("Administrator"); - _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); + _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); _failover.FailOverSiteAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new SiteFailoverOutcome( false, null, "Site did not respond: Ask timed out"))); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListsPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListsPageTests.cs index 04d3ec46..196f7818 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListsPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListsPageTests.cs @@ -191,7 +191,7 @@ public class NotificationListsPageTests : BunitContext /// A dialog service that auto-confirms, so action paths run end-to-end. private sealed class AlwaysConfirmDialogService : IDialogService { - public Task ConfirmAsync(string title, string message, bool danger = false) + public Task ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) => Task.FromResult(true); public Task PromptAsync( diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs index 0c238a2a..d1daade5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs @@ -336,7 +336,7 @@ public class NotificationReportDetailModalTests : BunitContext private sealed class AlwaysConfirmDialogService : IDialogService { - public Task ConfirmAsync(string title, string message, bool danger = false) + public Task ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) => Task.FromResult(true); public Task PromptAsync( diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportPageTests.cs index c5fc15d5..7d9fda24 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportPageTests.cs @@ -271,7 +271,7 @@ public class NotificationReportPageTests : BunitContext /// A dialog service that auto-confirms, so action paths run end-to-end. private sealed class AlwaysConfirmDialogService : IDialogService { - public Task ConfirmAsync(string title, string message, bool danger = false) + public Task ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) => Task.FromResult(true); public Task PromptAsync( diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs index 5026caf6..72521239 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs @@ -211,7 +211,7 @@ public sealed class QueryStringDrillInTests private sealed class AlwaysConfirmDialogService : IDialogService { - public Task ConfirmAsync(string title, string message, bool danger = false) + public Task ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) => Task.FromResult(true); public Task PromptAsync(string title, string label, string initialValue = "", string? placeholder = null) => Task.FromResult(null); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs index d32ebff3..e7629476 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs @@ -679,7 +679,7 @@ public class SiteCallsReportPageTests : BunitContext /// A dialog service that auto-confirms, so action paths run end-to-end. private sealed class AlwaysConfirmDialogService : IDialogService { - public Task ConfirmAsync(string title, string message, bool danger = false) + public Task ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) => Task.FromResult(true); public Task PromptAsync(