using Bunit; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; using ZB.MOM.WW.ScadaBridge.CentralUI.Services; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; /// /// bUnit tests for the body component /// (M9-T24b, migrated to the DialogService.ShowAsync host in T33b). The dialog is /// now opened through : a single /// DialogHost owns the backdrop/header/focus-trap, and the body renders only /// the target-site picker (excluding the connection's current site) plus its own /// action buttons. On confirm the body dispatches a MoveDataConnectionCommand /// through the management path via — the /// SAME guard-running seam the server uses. A guard error must be shown inline and /// the dialog must stay open (the awaited ShowAsync task must NOT complete); a /// success must close the dialog and resolve the task with true so the page /// reloads the tree. /// /// The tests render a real behind a live /// DialogHost and open the body via ShowAsync<bool> so the /// host-driven flow is exercised end to end. The move service is substituted so the /// tests capture the dispatched (connectionId, targetSiteId) and simulate both a /// success and a guard-error response without a real ManagementActor in scope. bUnit /// cannot run real JS interop, so the host's sbDialog.* focus calls run in /// mode where they become no-ops. /// public class MoveDataConnectionDialogTests : BunitContext { private readonly IDataConnectionMoveService _service = Substitute.For(); private readonly DialogService _dialog = new(); public MoveDataConnectionDialogTests() { Services.AddSingleton(_service); // Register the concrete service as the interface so DialogHost (which injects // IDialogService and casts to DialogService) resolves the same instance the // test drives. Services.AddSingleton(_dialog); JSInterop.Mode = JSRuntimeMode.Loose; } /// /// Renders a live DialogHost and opens the move dialog via /// ShowAsync<bool>, returning the rendered host and the pending task /// the page would await. /// private (IRenderedComponent Host, Task Pending) OpenDialog( IRenderedComponent host, int connectionId = 100, string connectionName = "PLC-1", IEnumerable<(int Id, string Label)>? siteOptions = null) { siteOptions ??= new[] { (2, "Plant-B"), (3, "Plant-C") }; Task pending = null!; host.InvokeAsync(() => { pending = _dialog.ShowAsync( $"Move '{connectionName}' to site…", ctx => builder => { builder.OpenComponent(0); builder.AddAttribute(1, nameof(MoveDataConnectionDialog.Context), ctx); builder.AddAttribute(2, nameof(MoveDataConnectionDialog.ConnectionId), connectionId); builder.AddAttribute(3, nameof(MoveDataConnectionDialog.ConnectionName), connectionName); builder.AddAttribute(4, nameof(MoveDataConnectionDialog.SiteOptions), siteOptions); builder.CloseComponent(); }); }).GetAwaiter().GetResult(); host.Render(); return (host, pending); } [Fact] public void ShownViaHost_RendersTargetSitePicker_WithSuppliedOptions() { // (a) When opened via ShowAsync, the body renders a site picker whose options // are exactly the supplied target sites (the page excludes the current site), // and the connection name appears in the host header title. var host = Render(); var (cut, _) = OpenDialog(host, siteOptions: new[] { (2, "Plant-B"), (3, "Plant-C") }); var options = cut.FindAll("select option"); Assert.Contains(options, o => o.TextContent.Contains("Plant-B")); Assert.Contains(options, o => o.TextContent.Contains("Plant-C")); Assert.Contains("PLC-1", cut.Markup); } [Fact] public void Confirm_DispatchesMoveCommand_WithConnectionAndTargetSiteIds() { // (b) Confirming dispatches the move through the management-dispatch service // with the connection id + the selected target site id. _service.MoveAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(DataConnectionMoveResult.Ok())); var host = Render(); var (cut, _) = OpenDialog(host, connectionId: 100, siteOptions: new[] { (2, "Plant-B"), (3, "Plant-C") }); // Pick Plant-C (id 3) and confirm. cut.Find("select").Change("3"); cut.FindAll("button").First(b => b.TextContent.Contains("Move")).Click(); _service.Received(1).MoveAsync(100, 3, Arg.Any()); } [Fact] public void GuardError_IsShownInline_AndDialogStaysOpen() { // (c) A guard-error response is rendered inline and the dialog does NOT close: // the awaited ShowAsync task stays incomplete (no result resolved). const string guardError = "Cannot move data connection 'PLC-1' (ID 100): it is referenced by 1 instance binding(s)."; _service.MoveAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(DataConnectionMoveResult.Fail(guardError))); var host = Render(); var (cut, pending) = OpenDialog(host, connectionId: 100, siteOptions: new[] { (2, "Plant-B") }); cut.Find("select").Change("2"); cut.FindAll("button").First(b => b.TextContent.Contains("Move")).Click(); Assert.Contains(guardError, cut.Markup); Assert.False(pending.IsCompleted, "Dialog must stay open on a guard error."); // The body is still rendered inside the host (not torn down). Assert.NotNull(cut.Find("[data-test='move-connection-error']")); } [Fact] public async Task Success_ClosesDialog_AndResolvesTrue() { // (d) A successful move closes the dialog and resolves the awaited task with // true so the page reloads the tree. _service.MoveAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(DataConnectionMoveResult.Ok())); var host = Render(); var (cut, pending) = OpenDialog(host, connectionId: 100, siteOptions: new[] { (2, "Plant-B") }); cut.Find("select").Change("2"); cut.FindAll("button").First(b => b.TextContent.Contains("Move")).Click(); var completed = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(2))); Assert.Same(pending, completed); Assert.True(await pending, "Dialog must resolve true on success so the page refreshes."); } }