feat(centralui): Topology 'Deploy all…' on site and area nodes with confirm dialog, Force and summary toast (Gitea #40)

This commit is contained in:
Joseph Doherty
2026-10-22 14:16:08 -04:00
parent d71f61e71b
commit bcd087efe8
6 changed files with 314 additions and 4 deletions
@@ -302,6 +302,42 @@ public class TopologyAreaTests : IClassFixture<DeploymentFixture>
}
}
/// <summary>Verifies that right-click area, Deploy all, confirm raises the summary toast (Gitea #40).</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task DeployAll_OnArea_ShowsSummaryToast()
{
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
var areaName = CliRunner.UniqueName("dpall");
var areaId = await CliRunner.CreateAreaAsync(_cluster.SiteAId, areaName);
var instanceId = await CliRunner.CreateInstanceAsync(
CliRunner.UniqueName("zztest-inst-dpall"), _cluster.TemplateId, _cluster.SiteAId, areaId);
try
{
var page = await OpenStableTopologyAsync(_pw);
var row = page.Locator("div.tv-row", new() { HasText = areaName });
await Assertions.Expect(row.First).ToBeVisibleAsync(new() { Timeout = 10_000 });
await row.First.ClickAsync(new() { Button = MouseButton.Right });
await page.Locator(".dropdown-menu.show button.dropdown-item:has-text('Deploy all')").ClickAsync();
var dialog = page.Locator(".modal.show:has([data-test='deploy-all-body'])");
await Assertions.Expect(dialog).ToBeVisibleAsync(new() { Timeout = 10_000 });
await Assertions.Expect(dialog.Locator("[data-test='deploy-all-counts']")).ToContainTextAsync("1 instance");
await dialog.Locator("[data-test='deploy-all-confirm']").ClickAsync();
var toast = page.Locator(".toast", new() { HasText = $"Deploy all under '{areaName}'" });
await Assertions.Expect(toast).ToBeVisibleAsync(new() { Timeout = 60_000 });
await Assertions.Expect(toast).ToContainTextAsync("deployed");
}
finally
{
await CliRunner.DeleteInstanceAsync(instanceId);
await CliRunner.DeleteAreaAsync(areaId);
}
}
private static Task<IPage> OpenStableTopologyAsync(PlaywrightFixture pw) =>
TopologyPageHelper.OpenStableTopologyAsync(pw);
}
@@ -138,9 +138,7 @@ public class TopologyDisableConfirmLabelTests : BunitContext
cut.InvokeAsync(() => cut.FindComponent<TopologyPage>().Instance.LoadBranchAsync(1, null)).GetAwaiter().GetResult();
cut.FindComponent<TopologyPage>().Render();
cut.FindAll(".tv-row").First(r => r.QuerySelector(".tv-label")?.TextContent == "Plant-A")
.QuerySelector(".tv-toggle")!.Click();
// Sites open pre-expanded (Gitea #39), so no toggle click is needed.
var row = cut.FindAll(".tv-row").First(r => r.QuerySelector(".tv-label")?.TextContent == "Pump-001");
row.ContextMenu();
@@ -244,6 +244,135 @@ public class TopologyPageTests : BunitContext
_repo.DidNotReceive().GetAllTemplatesAsync(Arg.Any<CancellationToken>());
}
// ---- Deploy all (Gitea #40) ----
// One site (id 1) with one area (id 10) holding one Disabled instance — a bulk deploy
// skips Disabled instances before any flatten, so the real DeploymentService runs
// end to end against the substituted repositories.
private IRenderedComponent<DialogHost> SeedDeployAllTopology(int instanceCount = 1)
{
var site = new Site("Plant-A", "plant-a") { Id = 1 };
var area = new Area("Line-1") { Id = 10, SiteId = 1 };
var instances = Enumerable.Range(1, instanceCount)
.Select(i => new Instance($"Pump-{i:000}") { Id = 100 + i, SiteId = 1, AreaId = 10, State = InstanceState.Disabled })
.ToList();
SeedRepos(sites: [site], instances: instances, areasBySite: new() { [1] = new List<Area> { area } });
_siteRepo.GetSiteByIdAsync(1, Arg.Any<CancellationToken>()).Returns(site);
_siteRepo.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>(instances));
_repo.GetAreaByIdAsync(10, Arg.Any<CancellationToken>()).Returns(area);
_pipeline.CreateSession().Returns(_ => new FlattenSession(new ZB.MOM.WW.ScadaBridge.Commons.Types.Templates.TemplateGraphWatermark()));
JSInterop.Mode = JSRuntimeMode.Loose;
return Render<DialogHost>();
}
private static void OpenMenu(IRenderedComponent<TopologyPage> cut, string label) =>
cut.FindAll(".tv-row").First(r => r.QuerySelector(".tv-label")?.TextContent == label).ContextMenu();
/// <summary>Deploy all appears on site and area context menus, not instance menus.</summary>
[Fact]
public void DeployAll_MenuItem_OnSiteAndAreaNodes_NotInstances()
{
SeedDeployAllTopology();
var cut = RenderLoadedTopology();
FindToggleForLabel(cut, "Line-1")!.Click();
OpenMenu(cut, "Pump-001");
Assert.DoesNotContain(cut.FindAll("button.dropdown-item"), b => b.TextContent.Trim() == "Deploy all…");
OpenMenu(cut, "Plant-A");
Assert.Contains(cut.FindAll("button.dropdown-item"), b => b.TextContent.Trim() == "Deploy all…");
OpenMenu(cut, "Line-1");
Assert.Contains(cut.FindAll("button.dropdown-item"), b => b.TextContent.Trim() == "Deploy all…");
}
/// <summary>Confirming on an area calls the area deploy and toasts the summary, then reloads.</summary>
[Fact]
public void DeployAll_Area_Confirm_DeploysAndShowsSummaryToast()
{
var host = SeedDeployAllTopology();
var cut = RenderLoadedTopology();
OpenMenu(cut, "Line-1");
cut.FindAll("button.dropdown-item").First(b => b.TextContent.Trim() == "Deploy all…").Click();
host.Render();
var dialog = host.WaitForElement("[data-test='deploy-all-body']");
Assert.Contains("1 instance", host.Find("[data-test='deploy-all-counts']").TextContent);
Assert.Contains("1 disabled", host.Find("[data-test='deploy-all-counts']").TextContent);
host.Find("[data-test='deploy-all-confirm']").Click();
cut.WaitForAssertion(() => Assert.Contains("0 deployed, 1 skipped, 0 failed", cut.Markup));
// preview + deploy each resolve the area.
_repo.Received(2).GetAreaByIdAsync(10, Arg.Any<CancellationToken>());
}
/// <summary>Confirming on a site calls the site deploy.</summary>
[Fact]
public void DeployAll_Site_Confirm_DeploysWholeSite()
{
var host = SeedDeployAllTopology(instanceCount: 2);
var cut = RenderLoadedTopology();
OpenMenu(cut, "Plant-A");
cut.FindAll("button.dropdown-item").First(b => b.TextContent.Trim() == "Deploy all…").Click();
host.Render();
Assert.Contains("2 instances", host.WaitForElement("[data-test='deploy-all-counts']").TextContent);
host.Find("[data-test='deploy-all-confirm']").Click();
cut.WaitForAssertion(() => Assert.True(cut.Markup.Contains("0 deployed, 2 skipped, 0 failed"), "toast missing"));
_siteRepo.Received(2).GetSiteByIdAsync(1, Arg.Any<CancellationToken>());
_repo.DidNotReceive().GetAreaByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
}
/// <summary>Cancel performs no deploy (only the preview read happened).</summary>
[Fact]
public void DeployAll_Cancel_DoesNothing()
{
var host = SeedDeployAllTopology();
var cut = RenderLoadedTopology();
OpenMenu(cut, "Plant-A");
cut.FindAll("button.dropdown-item").First(b => b.TextContent.Trim() == "Deploy all…").Click();
host.Render();
host.WaitForElement("[data-test='deploy-all-cancel']").Click();
cut.WaitForAssertion(() => Assert.DoesNotContain("skipped", cut.Markup));
// Only the preview's single read of the site's instances; a deploy would add a second.
_siteRepo.Received(1).GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>());
Assert.Empty(host.FindAll("[data-test='deploy-all-body']"));
}
/// <summary>A subtree with no instances shows an info toast and never opens the dialog.</summary>
[Fact]
public void DeployAll_ZeroInstances_InfoToast_NoDialog()
{
var host = SeedDeployAllTopology(instanceCount: 0);
var cut = RenderLoadedTopology();
OpenMenu(cut, "Plant-A");
cut.FindAll("button.dropdown-item").First(b => b.TextContent.Trim() == "Deploy all…").Click();
cut.WaitForAssertion(() => Assert.Contains("No instances under", cut.Markup));
host.Render();
Assert.Empty(host.FindAll("[data-test='deploy-all-body']"));
}
/// <summary>The dialog's Force checkbox is carried on the confirmed result.</summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task DeployAllDialog_ConfirmCarriesForceChoice(bool force)
{
JSInterop.Mode = JSRuntimeMode.Loose;
var host = Render<DialogHost>();
var dialogs = Services.GetRequiredService<IDialogService>();
var pending = dialogs.ShowAsync<DeployAllDialog.DeployAllResult>("Deploy all", ctx => builder =>
{
builder.OpenComponent<DeployAllDialog>(0);
builder.AddAttribute(1, "Context", ctx);
builder.AddAttribute(2, "ScopeLabel", "site Plant-A");
builder.AddAttribute(3, "Total", 4);
builder.AddAttribute(4, "Disabled", 1);
builder.CloseComponent();
});
host.Render();
if (force) host.WaitForElement("[data-test='deploy-all-force']").Change(true);
host.WaitForElement("[data-test='deploy-all-confirm']").Click();
var result = await pending.WaitAsync(TimeSpan.FromSeconds(5));
Assert.NotNull(result);
Assert.Equal(force, result!.Force);
}
/// <summary>Gitea #39: the first layer (areas of every permitted site) is loaded at open; instances stay lazy.</summary>
[Fact]
public void InitialLoad_PreloadsAreasForPermittedSites_ButNoInstances()