test(centralui): pin Topology Disable/Delete confirm verbs (register row 28)

Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
This commit is contained in:
Joseph Doherty
2026-08-21 07:45:46 -04:00
parent 7c2582d105
commit 1790f67ebd
@@ -0,0 +1,179 @@
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
using TopologyPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.Topology;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Pins the verbs on the Topology instance-action confirmations (deferred-work
/// register row 28): <em>Disable</em> must read "Disable", not the red button's
/// historical "Delete" default, while a genuine Delete must still read "Delete".
/// Like <c>FailoverConfirmLabelTests</c> these run the real <see cref="DialogService"/>
/// behind a real <c>DialogHost</c>, so they pin what the user reads rather than
/// the argument a substitute received.
/// </summary>
public class TopologyDisableConfirmLabelTests : BunitContext
{
private readonly ITemplateEngineRepository _repo = Substitute.For<ITemplateEngineRepository>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly IDeploymentManagerRepository _deployRepo = Substitute.For<IDeploymentManagerRepository>();
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
private readonly IAuditService _audit = Substitute.For<IAuditService>();
private readonly DialogService _dialog = new();
public TopologyDisableConfirmLabelTests()
{
Services.AddSingleton(_repo);
Services.AddSingleton(_siteRepo);
Services.AddSingleton(_deployRepo);
Services.AddSingleton(_pipeline);
Services.AddSingleton(_audit);
// DeploymentService has non-mockable concrete deps; instantiate them directly
// (the same DI block TopologyPageTests uses to render this page).
Services.AddSingleton(new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance));
Services.AddSingleton(new OperationLockManager());
Services.AddSingleton(Options.Create(new DeploymentManagerOptions
{
OperationLockTimeout = TimeSpan.FromSeconds(5)
}));
Services.AddSingleton(Options.Create(new CommunicationOptions()));
Services.AddSingleton<ILogger<DeploymentService>>(NullLogger<DeploymentService>.Instance);
Services.AddScoped<ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.DiffService>();
Services.AddScoped<ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.RevisionHashService>();
Services.AddSingleton<IDeploymentStatusNotifier>(
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance));
Services.AddSingleton(Substitute.For<IStaleInstanceProbe>());
Services.AddScoped<DeploymentService>();
Services.AddScoped<AreaService>();
Services.AddScoped<InstanceService>();
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
// The concrete service registered as the interface, so the page and the
// host share one instance (DialogHost injects IDialogService and casts).
Services.AddSingleton<IDialogService>(_dialog);
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, Roles.Deployer)
};
Services.AddSingleton<AuthenticationStateProvider>(
new TestAuthStateProvider(new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"))));
Services.AddAuthorizationCore();
JSInterop.Mode = JSRuntimeMode.Loose;
}
/// <summary>One site with one enabled instance, so both Disable and Delete are offered.</summary>
private void SeedOneEnabledInstance()
{
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(
new List<Site> { new("Plant-A", "plant-a") { Id = 1 } }));
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template>()));
_repo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance>
{
new("Pump-001") { Id = 100, SiteId = 1, State = InstanceState.Enabled }
}));
_repo.GetAreasBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Area>>(new List<Area>()));
}
/// <summary>Renders Topology next to a live DialogHost, as MainLayout does.</summary>
private IRenderedComponent<TestHostWrapper> RenderPageWithHost() => Render<TestHostWrapper>();
/// <summary>Wrapper so the page and the dialog host share one render tree.</summary>
public sealed class TestHostWrapper : ComponentBase
{
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenComponent<TopologyPage>(0);
builder.CloseComponent();
builder.OpenComponent<DialogHost>(1);
builder.CloseComponent();
}
}
/// <summary>
/// Expands to the instance row, opens its context menu and clicks the named item,
/// then returns the confirmation modal's footer buttons.
/// </summary>
/// <remarks>
/// The menu click is deliberately not awaited: with a real DialogService the page's
/// handler parks on <c>await ConfirmAsync(...)</c> until the dialog is answered.
/// </remarks>
private (IRenderedComponent<TestHostWrapper> cut, IReadOnlyList<AngleSharp.Dom.IElement> footer)
OpenInstanceConfirm(string menuItem)
{
SeedOneEnabledInstance();
var cut = RenderPageWithHost();
cut.FindAll(".tv-row").First(r => r.QuerySelector(".tv-label")?.TextContent == "Plant-A")
.QuerySelector(".tv-toggle")!.Click();
var row = cut.FindAll(".tv-row").First(r => r.QuerySelector(".tv-label")?.TextContent == "Pump-001");
row.ContextMenu();
_ = cut.Find(".dropdown-menu.show")
.QuerySelectorAll("button.dropdown-item")
.First(b => b.TextContent.Trim() == menuItem)
.ClickAsync(new());
cut.WaitForState(() => cut.FindAll("div.modal-footer button").Count > 0);
return (cut, cut.FindAll("div.modal-footer button").ToList());
}
[Fact]
public void Disable_confirm_button_says_disable_not_delete()
{
var (_, footer) = OpenInstanceConfirm("Disable");
var confirm = footer.Last();
Assert.Equal("Disable", confirm.TextContent.Trim());
Assert.Contains("btn-danger", confirm.ClassList);
Assert.Contains("Cancel", footer.Select(b => b.TextContent.Trim()));
}
[Fact]
public void Disable_confirm_body_names_the_instance()
{
var (cut, _) = OpenInstanceConfirm("Disable");
Assert.Contains("Disable instance 'Pump-001'?", cut.Find(".modal-body").TextContent);
}
[Fact]
public void Delete_confirm_button_still_says_delete()
{
// The negative twin: relabelling Disable must not have changed the genuine
// delete path, which relies on the danger-confirm default.
var confirm = OpenInstanceConfirm("Delete").footer.Last();
Assert.Equal("Delete", confirm.TextContent.Trim());
Assert.Contains("btn-danger", confirm.ClassList);
}
}