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;
///
/// bUnit tests for the Create Instance template field: a read-only path box plus a
/// Choose… button that opens TemplatePickerDialog, and the ?templateId=
/// query preselect (including the dangling-id hint and the no-templates empty state).
///
public class InstanceCreateTemplateFieldTests : BunitContext
{
private readonly ITemplateEngineRepository _repo = Substitute.For();
private readonly ISiteRepository _siteRepo = Substitute.For();
private readonly IDeploymentManagerRepository _deployRepo = Substitute.For();
private readonly IFlatteningPipeline _pipeline = Substitute.For();
private readonly IAuditService _audit = Substitute.For();
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());
// DeploymentService / InstanceService have non-mockable concrete deps;
// instantiate them directly (same block TopologyPageTests uses).
Services.AddSingleton(new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger.Instance));
Services.AddSingleton(new OperationLockManager());
Services.AddSingleton(Options.Create(new DeploymentManagerOptions
{
OperationLockTimeout = TimeSpan.FromSeconds(5)
}));
Services.AddSingleton(Options.Create(new CommunicationOptions()));
Services.AddSingleton>(NullLogger.Instance);
Services.AddScoped();
Services.AddScoped();
Services.AddSingleton(
new DeploymentStatusNotifier(NullLogger.Instance));
Services.AddSingleton(Substitute.For());
Services.AddScoped();
Services.AddScoped();
Services.AddScoped();
Services.AddScoped();
// A real DialogService behind a real DialogHost so Choose… actually opens.
Services.AddSingleton(_dialog);
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Deployer")
};
Services.AddSingleton(
new TestAuthStateProvider(new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"))));
Services.AddAuthorizationCore();
JSInterop.Mode = JSRuntimeMode.Loose;
}
/// Folders A → B with template 7 "Deep"; folder Other with template 8 "Elsewhere".
private void SeedCatalogue(bool empty = false, bool withSite = false)
{
var folders = empty
? new List()
: new List
{
new("A") { Id = 1 },
new("B") { Id = 2, ParentFolderId = 1 },
new("Other") { Id = 3 },
};
var templates = empty
? new List()
: new List
{
new("Deep") { Id = 7, FolderId = 2 },
new("Elsewhere") { Id = 8, FolderId = 3 },
};
_repo.GetAllTemplatesAsync(Arg.Any())
.Returns(Task.FromResult>(templates));
_repo.GetAllFoldersAsync(Arg.Any())
.Returns(Task.FromResult>(folders));
var sites = withSite
? new List { new("Site A", "site-a") { Id = 1 } }
: new List();
_siteRepo.GetAllSitesAsync(Arg.Any())
.Returns(Task.FromResult>(sites));
_siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any(), Arg.Any())
.Returns(Task.FromResult>(new List()));
_repo.GetAreasBySiteIdAsync(Arg.Any(), Arg.Any())
.Returns(Task.FromResult>(new List()));
_pipeline.PreviewAsync(Arg.Any(), Arg.Any(), Arg.Any())
.Returns(Task.FromResult(Result.Success(new FlattenedConfiguration())));
}
///
/// Renders the page and a DialogHost side by side (as MainLayout does) after seeding
/// the query string. [SupplyParameterFromQuery] properties are cascading-only,
/// so they cannot be Add()-ed as ordinary parameters — the URL is the only way in.
///
private IRenderedComponent RenderPage(int? templateId = null, int? siteId = null)
{
var query = new List();
if (templateId is int t) query.Add($"templateId={t}");
if (siteId is int s) query.Add($"siteId={s}");
var nav = (BunitNavigationManager)Services.GetRequiredService();
nav.NavigateTo("/deployment/instances/create" + (query.Count > 0 ? "?" + string.Join("&", query) : ""));
return Render();
}
/// Wrapper that renders InstanceCreate next to a DialogHost, as MainLayout does.
public sealed class TestHostWrapper : ComponentBase
{
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenComponent(0);
builder.CloseComponent();
builder.OpenComponent(1);
builder.CloseComponent();
}
}
private static void ExpandFolder(IRenderedComponent 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());
}
}