Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceCreateTemplateFieldTests.cs
T
2026-09-09 16:13:58 -04:00

217 lines
9.8 KiB
C#

using System.Security.Claims;
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Authorization;
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;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
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 InstanceCreatePage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.InstanceCreate;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// bUnit tests for the Create Instance template field: a read-only path box plus a
/// Choose… button that opens <c>TemplatePickerDialog</c>, and the <c>?templateId=</c>
/// query preselect (including the dangling-id hint and the no-templates empty state).
/// </summary>
public class InstanceCreateTemplateFieldTests : 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 InstanceCreateTemplateFieldTests()
{
Services.AddSingleton(_repo);
Services.AddSingleton(_siteRepo);
Services.AddSingleton(_deployRepo);
Services.AddSingleton(_pipeline);
Services.AddSingleton(_audit);
// The Connection Bindings card renders NodeBrowserDialog once a site is chosen.
Services.AddSingleton(Substitute.For<ZB.MOM.WW.ScadaBridge.CentralUI.Services.IBrowseService>());
// DeploymentService / InstanceService have non-mockable concrete deps;
// instantiate them directly (same block TopologyPageTests uses).
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>();
// A real DialogService behind a real DialogHost so Choose… actually opens.
Services.AddSingleton<IDialogService>(_dialog);
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Deployer")
};
Services.AddSingleton<AuthenticationStateProvider>(
new TestAuthStateProvider(new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"))));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
JSInterop.Mode = JSRuntimeMode.Loose;
}
/// <summary>Folders A → B with template 7 "Deep"; folder Other with template 8 "Elsewhere".</summary>
private void SeedCatalogue(bool empty = false, bool withSite = false)
{
var folders = empty
? new List<TemplateFolder>()
: new List<TemplateFolder>
{
new("A") { Id = 1 },
new("B") { Id = 2, ParentFolderId = 1 },
new("Other") { Id = 3 },
};
var templates = empty
? new List<Template>()
: new List<Template>
{
new("Deep") { Id = 7, FolderId = 2 },
new("Elsewhere") { Id = 8, FolderId = 3 },
};
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(templates));
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(folders));
var sites = withSite
? new List<Site> { new("Site A", "site-a") { Id = 1 } }
: new List<Site>();
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(sites));
_siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<DataConnection>>(new List<DataConnection>()));
_repo.GetAreasBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Area>>(new List<Area>()));
_pipeline.PreviewAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Result<FlattenedConfiguration>.Success(new FlattenedConfiguration())));
}
/// <summary>
/// Renders the page and a DialogHost side by side (as MainLayout does) after seeding
/// the query string. <c>[SupplyParameterFromQuery]</c> properties are cascading-only,
/// so they cannot be Add()-ed as ordinary parameters — the URL is the only way in.
/// </summary>
private IRenderedComponent<TestHostWrapper> RenderPage(int? templateId = null, int? siteId = null)
{
var query = new List<string>();
if (templateId is int t) query.Add($"templateId={t}");
if (siteId is int s) query.Add($"siteId={s}");
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/deployment/instances/create" + (query.Count > 0 ? "?" + string.Join("&", query) : ""));
return Render<TestHostWrapper>();
}
/// <summary>Wrapper that renders InstanceCreate next to a DialogHost, as MainLayout does.</summary>
public sealed class TestHostWrapper : ComponentBase
{
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenComponent<InstanceCreatePage>(0);
builder.CloseComponent();
builder.OpenComponent<DialogHost>(1);
builder.CloseComponent();
}
}
private static void ExpandFolder(IRenderedComponent<TestHostWrapper> cut, string label) =>
cut.FindAll("li[role='treeitem']")
.First(li => li.QuerySelector(".tv-label")!.TextContent.Trim() == label)
.QuerySelector(".tv-toggle")!.Click();
[Fact]
public void TemplateField_IsReadOnlyWithChooseButton_AndPlaceholder()
{
SeedCatalogue();
var cut = RenderPage();
var field = cut.Find("input[data-test='create-template']");
Assert.True(field.HasAttribute("readonly"));
Assert.Equal("Choose a template…", field.GetAttribute("placeholder"));
Assert.NotNull(cut.Find("button[data-test='create-template-choose']"));
}
[Fact]
public void TemplateIdQuery_PrefillsFieldWithFolderPath()
{
SeedCatalogue();
var cut = RenderPage(templateId: 7);
Assert.Equal("A / B / Deep", cut.Find("input[data-test='create-template']").GetAttribute("value"));
}
[Fact]
public void UnknownTemplateIdQuery_LeavesFieldEmpty_AndShowsHint()
{
SeedCatalogue();
var cut = RenderPage(templateId: 999);
Assert.Equal("", cut.Find("input[data-test='create-template']").GetAttribute("value") ?? "");
Assert.Contains("Template 999 no longer exists", cut.Markup);
}
[Fact]
public void NoTemplates_DisablesChoose_AndLinksToTemplatesPage()
{
SeedCatalogue(empty: true);
var cut = RenderPage();
Assert.True(cut.Find("button[data-test='create-template-choose']").HasAttribute("disabled"));
Assert.Contains("/design/templates", cut.Markup);
}
[Fact]
public async Task ChoosingInPicker_SetsTemplate_AndRePreviewsBindingsWhenSiteSet()
{
SeedCatalogue(withSite: true);
var cut = RenderPage(siteId: 1);
cut.Find("button[data-test='create-template-choose']").Click();
cut.WaitForAssertion(() => Assert.NotNull(cut.Find("button[data-test='picker-select']")));
ExpandFolder(cut, "A");
ExpandFolder(cut, "B");
cut.FindAll(".tv-label").First(l => l.TextContent.Trim() == "Deep").Click();
cut.Find("button[data-test='picker-select']").Click();
cut.WaitForAssertion(() => Assert.Equal(
"A / B / Deep", cut.Find("input[data-test='create-template']").GetAttribute("value")));
await _pipeline.Received(1).PreviewAsync(7, 1, Arg.Any<CancellationToken>());
}
}