1f261263b2
The templates tree rendered a derived/composed member (e.g. LeftReactorSide, derived from ReactorSide) as a flat leaf, omitting compositions it inherits from its base (e.g. LeakTest composed onto ReactorSide). BuildCompositionLeavesFor recursed only over a template's OWN composition rows; an inherited composition row lives on the ancestor, and TemplateComposition has no IsInherited placeholder (unlike attributes/alarms/scripts/native-sources), so the child's own Compositions was empty. Same 'derived templates don't surface inherited members' family as followups #1/#2, but for compositions. Deploy/flatten was always correct (TemplateResolver.ResolveAllMembers walks the chain) — display-only. Fix: - BuildCompositionLeavesFor now renders the EFFECTIVE composition set (own + inherited) via EffectiveCompositionsFor, which walks the inheritance chain (leaf->root, child wins on InstanceName), mirroring the resolver. - Inherited slots are flagged (TemplateTreeNode.IsInherited), badged 'inherited' in the label, and their context menu offers only 'Open composed template' (Rename/Delete edit the ancestor's slot, so suppressed on inherited nodes). - The same inherited row can appear under several derived members (LeakTest under both LeftReactorSide and RightReactorSide), so composition nodes use a path-qualified KeyOverride to keep TreeView selection/expansion keys unique; recursion is cycle-guarded. Tests: +1 bUnit (TemplatesPageTests.Renders_InheritedComposition_UnderDerivedComposedMember); CentralUI suite 867 green; full solution builds 0/0. Docs: Component-CentralUI.md (effective composition set in tree); known-issues tracker #9 recorded + resolved. Note: CentralUI change — shows on wonder-app-vd03 only after that host is redeployed.
408 lines
18 KiB
C#
408 lines
18 KiB
C#
using System.Security.Claims;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
using Bunit;
|
|
using Microsoft.AspNetCore.Components;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using NSubstitute;
|
|
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.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
|
|
using TemplatesPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.Templates;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
|
|
|
|
/// <summary>
|
|
/// bUnit rendering tests for the Templates page that verify the folder/template
|
|
/// tree builds the expected DOM for the main shape categories: empty state,
|
|
/// folder-containing-template nesting, and composition leaves under their owner.
|
|
/// </summary>
|
|
public class TemplatesPageTests : BunitContext
|
|
{
|
|
private readonly ITemplateEngineRepository _repo = Substitute.For<ITemplateEngineRepository>();
|
|
private readonly IAuditService _audit = Substitute.For<IAuditService>();
|
|
|
|
public TemplatesPageTests()
|
|
{
|
|
// The page's TemplateService / TemplateFolderService are constructed via DI
|
|
// from the repository and audit service, mirroring real Host wiring.
|
|
Services.AddSingleton(_repo);
|
|
Services.AddSingleton(_audit);
|
|
Services.AddScoped<TemplateService>();
|
|
Services.AddScoped<TemplateFolderService>();
|
|
// The Templates page injects IDialogService for the new-folder prompt
|
|
// and delete confirmations. The host is rendered in MainLayout, not
|
|
// here, but the DI registration still has to satisfy the [Inject].
|
|
Services.AddScoped<IDialogService, DialogService>();
|
|
AddTestAuth();
|
|
|
|
// The TreeView inside the page persists expansion state via JS interop
|
|
// against sessionStorage (`templates-tree` key). bUnit requires explicit
|
|
// stubs for all JS interop calls, otherwise rendering throws.
|
|
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
|
|
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
|
|
}
|
|
|
|
private void AddTestAuth()
|
|
{
|
|
// The page resolves the current user via the "Username" claim in
|
|
// GetCurrentUserAsync(); supply a stub so OnInitializedAsync doesn't crash.
|
|
var claims = new[]
|
|
{
|
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
|
new Claim(JwtTokenService.RoleClaimType, "Designer")
|
|
};
|
|
var identity = new ClaimsIdentity(claims, "TestAuth");
|
|
var user = new ClaimsPrincipal(identity);
|
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
|
Services.AddAuthorizationCore();
|
|
}
|
|
|
|
[Fact]
|
|
public void Renders_EmptyState_WhenNoTemplatesOrFolders()
|
|
{
|
|
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template>()));
|
|
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
|
|
|
|
var cut = Render<TemplatesPage>();
|
|
|
|
Assert.Contains("No templates yet", cut.Markup);
|
|
}
|
|
|
|
[Fact]
|
|
public void Renders_FolderAndTemplate_AtCorrectNesting()
|
|
{
|
|
var folder = new TemplateFolder("Dev") { Id = 1 };
|
|
var template = new Template("TestMachine") { Id = 5, FolderId = 1 };
|
|
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { template }));
|
|
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder> { folder }));
|
|
|
|
var cut = Render<TemplatesPage>();
|
|
|
|
// The folder is rendered collapsed; assert the folder label is present,
|
|
// then expand it and assert the nested template label appears.
|
|
Assert.Contains("Dev", cut.Markup);
|
|
|
|
var folderToggle = cut.FindAll("li[role='treeitem']")
|
|
.FirstOrDefault(li => li.TextContent.Contains("Dev"))
|
|
?.QuerySelector(".tv-toggle");
|
|
Assert.NotNull(folderToggle);
|
|
folderToggle!.Click();
|
|
|
|
Assert.Contains("TestMachine", cut.Markup);
|
|
}
|
|
|
|
[Fact]
|
|
public void Renders_CompositionChildren_UnderOwningTemplate()
|
|
{
|
|
var template = new Template("TestMachine") { Id = 5 };
|
|
template.Compositions.Add(
|
|
new TemplateComposition("DelmiaReceiver") { Id = 10, ComposedTemplateId = 99 });
|
|
var composed = new Template("Other") { Id = 99 };
|
|
|
|
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { template, composed }));
|
|
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
|
|
|
|
var cut = Render<TemplatesPage>();
|
|
|
|
// The owning template must be expanded for its composition leaves to be
|
|
// in the DOM — composition children only render under an expanded parent.
|
|
var ownerToggle = cut.FindAll("li[role='treeitem']")
|
|
.FirstOrDefault(li => li.TextContent.Contains("TestMachine"))
|
|
?.QuerySelector(".tv-toggle");
|
|
Assert.NotNull(ownerToggle);
|
|
ownerToggle!.Click();
|
|
|
|
Assert.Contains("DelmiaReceiver", cut.Markup);
|
|
// The composition glyph appears via Bootstrap Icons; the composed template name
|
|
// is intentionally not rendered on the tree (V7 spec).
|
|
Assert.Contains("bi-arrow-return-right", cut.Markup);
|
|
}
|
|
|
|
[Fact]
|
|
public void Renders_InheritedComposition_UnderDerivedComposedMember()
|
|
{
|
|
// followup #9: LeakTest is composed onto the BASE ReactorSide. LeftReactorSide is
|
|
// DERIVED from ReactorSide and composed into CvdReactor, so the tree must surface
|
|
// the INHERITED LeakTest slot under LeftReactorSide — the composition row lives on
|
|
// ReactorSide (no IsInherited placeholder on the child), so the builder must use
|
|
// the effective (own + inherited) composition set, mirroring the resolver.
|
|
var leakTest = new Template("LeakTest") { Id = 50 };
|
|
var reactorSide = new Template("ReactorSide") { Id = 7 };
|
|
reactorSide.Compositions.Add(
|
|
new TemplateComposition("LeakTest") { Id = 100, TemplateId = 7, ComposedTemplateId = 50 });
|
|
var leftReactorSide = new Template("LeftReactorSide") { Id = 8, ParentTemplateId = 7, IsDerived = true };
|
|
var cvdReactor = new Template("CvdReactor") { Id = 1 };
|
|
cvdReactor.Compositions.Add(
|
|
new TemplateComposition("LeftReactorSide") { Id = 200, TemplateId = 1, ComposedTemplateId = 8 });
|
|
|
|
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Template>>(
|
|
new List<Template> { cvdReactor, reactorSide, leftReactorSide, leakTest }));
|
|
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
|
|
|
|
var cut = Render<TemplatesPage>();
|
|
|
|
// Expand CvdReactor to reveal its composed member LeftReactorSide.
|
|
cut.FindAll("li[role='treeitem']")
|
|
.First(li => li.QuerySelector(".tv-label")?.TextContent.Trim() == "CvdReactor")
|
|
.QuerySelector(".tv-toggle")!.Click();
|
|
|
|
// LeftReactorSide must be expandable: before the fix it was a flat leaf with no
|
|
// toggle because its own Compositions are empty (LeakTest is inherited, not owned).
|
|
var leftLi = cut.FindAll("li[role='treeitem']")
|
|
.First(li => li.QuerySelector(".tv-label")?.TextContent.Trim() == "LeftReactorSide");
|
|
var leftToggle = leftLi.QuerySelector(".tv-toggle");
|
|
Assert.NotNull(leftToggle);
|
|
leftToggle!.Click();
|
|
|
|
// The inherited LeakTest slot now renders under LeftReactorSide, badged "inherited".
|
|
var leftLiAfter = cut.FindAll("li[role='treeitem']")
|
|
.First(li => li.QuerySelector(".tv-label")?.TextContent.Trim() == "LeftReactorSide");
|
|
Assert.Contains("LeakTest", leftLiAfter.TextContent);
|
|
Assert.Contains("inherited", leftLiAfter.TextContent);
|
|
}
|
|
|
|
[Fact]
|
|
public void SearchBox_IsPresentAndBound_ToTemplateFolderTreeFilter()
|
|
{
|
|
// Seed two templates in the same folder so we can confirm the filter narrows
|
|
// the visible set and that clearing the input restores the full tree.
|
|
var folder = new TemplateFolder("Controllers") { Id = 1 };
|
|
var alpha = new Template("AlphaDevice") { Id = 10, FolderId = 1 };
|
|
var beta = new Template("BetaDevice") { Id = 20, FolderId = 1 };
|
|
|
|
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { alpha, beta }));
|
|
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder> { folder }));
|
|
|
|
var cut = Render<TemplatesPage>();
|
|
|
|
// 1. A search input must exist in the rendered output.
|
|
var search = cut.Find("input[type='text'][placeholder*='earch']");
|
|
Assert.NotNull(search);
|
|
|
|
// 2. Typing a substring hides non-matching templates. The TemplateFolderTree
|
|
// filter auto-expands ancestors of matches via its _initiallyExpanded hook,
|
|
// so "AlphaDevice" appears without a manual toggle click. "BetaDevice" is
|
|
// absent from the filtered tree entirely.
|
|
search.Input("Alpha");
|
|
|
|
Assert.Contains("AlphaDevice", cut.Markup);
|
|
Assert.DoesNotContain("BetaDevice", cut.Markup);
|
|
|
|
// 3. Clearing the input restores the full tree. The folder may now be collapsed
|
|
// (prior expansion state is stored per-key; after filter-clear the tree uses
|
|
// saved state). Expand manually to verify both templates are reachable.
|
|
search.Input("");
|
|
|
|
// Both templates must be in the tree; expand the folder if needed.
|
|
var folderToggleAfter = cut.FindAll("li[role='treeitem']")
|
|
.FirstOrDefault(li => li.TextContent.Contains("Controllers"))
|
|
?.QuerySelector(".tv-toggle");
|
|
|
|
// Only click if the folder is not yet expanded (aria-expanded='false').
|
|
var folderLi = cut.FindAll("li[role='treeitem']")
|
|
.FirstOrDefault(li => li.TextContent.Contains("Controllers"));
|
|
if (folderLi?.GetAttribute("aria-expanded") != "true")
|
|
folderToggleAfter?.Click();
|
|
|
|
Assert.Contains("AlphaDevice", cut.Markup);
|
|
Assert.Contains("BetaDevice", cut.Markup);
|
|
}
|
|
|
|
// ========================================================================
|
|
// M9-T23b: folder sibling reorder menu items + root context menu
|
|
// ========================================================================
|
|
|
|
// Seeds two root sibling folders (Alpha @ SortOrder 0, Beta @ SortOrder 1) and
|
|
// wires the repo so TemplateFolderService.ReorderFolderAsync runs end-to-end.
|
|
private (TemplateFolder alpha, TemplateFolder beta) SeedTwoRootSiblings()
|
|
{
|
|
var alpha = new TemplateFolder("Alpha") { Id = 1, ParentFolderId = null, SortOrder = 0 };
|
|
var beta = new TemplateFolder("Beta") { Id = 2, ParentFolderId = null, SortOrder = 1 };
|
|
var all = new List<TemplateFolder> { alpha, beta };
|
|
|
|
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template>()));
|
|
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
|
|
.Returns(_ => Task.FromResult<IReadOnlyList<TemplateFolder>>(all));
|
|
_repo.GetFolderByIdAsync(1, Arg.Any<CancellationToken>()).Returns(Task.FromResult<TemplateFolder?>(alpha));
|
|
_repo.GetFolderByIdAsync(2, Arg.Any<CancellationToken>()).Returns(Task.FromResult<TemplateFolder?>(beta));
|
|
|
|
return (alpha, beta);
|
|
}
|
|
|
|
// Right-clicks the folder row whose label contains the given text, opening the
|
|
// TreeView context menu, then returns the rendered context-menu container.
|
|
private static AngleSharp.Dom.IElement OpenFolderContextMenu(IRenderedComponent<TemplatesPage> cut, string folderLabel)
|
|
{
|
|
var row = cut.FindAll("li[role='treeitem']")
|
|
.First(li => li.TextContent.Contains(folderLabel))
|
|
.QuerySelector(".tv-row")!;
|
|
row.ContextMenu();
|
|
return cut.Find(".dropdown-menu.show");
|
|
}
|
|
|
|
[Fact]
|
|
public void FolderContextMenu_ExposesMoveUpAndMoveDown()
|
|
{
|
|
SeedTwoRootSiblings();
|
|
|
|
var cut = Render<TemplatesPage>();
|
|
|
|
var menu = OpenFolderContextMenu(cut, "Beta");
|
|
var labels = menu.QuerySelectorAll("button.dropdown-item").Select(b => b.TextContent.Trim()).ToList();
|
|
|
|
Assert.Contains("Move up", labels);
|
|
Assert.Contains("Move down", labels);
|
|
}
|
|
|
|
[Fact]
|
|
public void FolderContextMenu_MoveDown_DispatchesReorderDown_AndReloads()
|
|
{
|
|
var (alpha, beta) = SeedTwoRootSiblings();
|
|
|
|
var cut = Render<TemplatesPage>();
|
|
|
|
// Move Alpha (first sibling) down -> swaps SortOrder with Beta (the next sibling).
|
|
var menu = OpenFolderContextMenu(cut, "Alpha");
|
|
menu.QuerySelectorAll("button.dropdown-item")
|
|
.First(b => b.TextContent.Trim() == "Move down")
|
|
.Click();
|
|
|
|
// Reorder was dispatched the same way other folder commands are — via
|
|
// TemplateFolderService, which resolves + persists both swapped siblings.
|
|
_repo.Received().GetFolderByIdAsync(1, Arg.Any<CancellationToken>());
|
|
_repo.Received().UpdateFolderAsync(alpha, Arg.Any<CancellationToken>());
|
|
_repo.Received().UpdateFolderAsync(beta, Arg.Any<CancellationToken>());
|
|
_repo.Received().SaveChangesAsync(Arg.Any<CancellationToken>());
|
|
|
|
// Down on Alpha swapped sort orders: Alpha now after Beta.
|
|
Assert.Equal(1, alpha.SortOrder);
|
|
Assert.Equal(0, beta.SortOrder);
|
|
|
|
// Tree reloaded after the mutation: LoadTemplatesAsync re-fetched (initial
|
|
// load + post-reorder reload). GetAllTemplatesAsync is only touched by the
|
|
// page's load path, never by ReorderFolderAsync, so 2 calls == one reload.
|
|
_repo.Received(2).GetAllTemplatesAsync(Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public void FolderContextMenu_MoveUp_DispatchesReorderUp_AndReloads()
|
|
{
|
|
var (alpha, beta) = SeedTwoRootSiblings();
|
|
|
|
var cut = Render<TemplatesPage>();
|
|
|
|
// Move Beta (second sibling) up -> swaps SortOrder with Alpha (the previous sibling).
|
|
var menu = OpenFolderContextMenu(cut, "Beta");
|
|
menu.QuerySelectorAll("button.dropdown-item")
|
|
.First(b => b.TextContent.Trim() == "Move up")
|
|
.Click();
|
|
|
|
_repo.Received().GetFolderByIdAsync(2, Arg.Any<CancellationToken>());
|
|
_repo.Received().UpdateFolderAsync(alpha, Arg.Any<CancellationToken>());
|
|
_repo.Received().UpdateFolderAsync(beta, Arg.Any<CancellationToken>());
|
|
|
|
// Up on Beta swapped sort orders: Beta now before Alpha.
|
|
Assert.Equal(0, beta.SortOrder);
|
|
Assert.Equal(1, alpha.SortOrder);
|
|
|
|
// Tree reloaded after the mutation (one reload == GetAllTemplatesAsync twice).
|
|
_repo.Received(2).GetAllTemplatesAsync(Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public void FolderContextMenu_MoveUp_IsDisabled_OnFirstSibling()
|
|
{
|
|
// Alpha is the first sibling (SortOrder 0) — Move up must be disabled.
|
|
SeedTwoRootSiblings();
|
|
|
|
var cut = Render<TemplatesPage>();
|
|
|
|
var menu = OpenFolderContextMenu(cut, "Alpha");
|
|
var moveUpBtn = menu.QuerySelectorAll("button.dropdown-item")
|
|
.First(b => b.TextContent.Trim() == "Move up");
|
|
|
|
Assert.True(moveUpBtn.HasAttribute("disabled"), "Move up should be disabled for the first sibling");
|
|
}
|
|
|
|
[Fact]
|
|
public void FolderContextMenu_MoveDown_IsDisabled_OnLastSibling()
|
|
{
|
|
// Beta is the last sibling (SortOrder 1) — Move down must be disabled.
|
|
SeedTwoRootSiblings();
|
|
|
|
var cut = Render<TemplatesPage>();
|
|
|
|
var menu = OpenFolderContextMenu(cut, "Beta");
|
|
var moveDownBtn = menu.QuerySelectorAll("button.dropdown-item")
|
|
.First(b => b.TextContent.Trim() == "Move down");
|
|
|
|
Assert.True(moveDownBtn.HasAttribute("disabled"), "Move down should be disabled for the last sibling");
|
|
}
|
|
|
|
[Fact]
|
|
public void RootContextMenu_OffersNewFolderAndNewTemplate()
|
|
{
|
|
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template>()));
|
|
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
|
|
|
|
var cut = Render<TemplatesPage>();
|
|
|
|
// Right-click the root tree zone to open the root context menu.
|
|
cut.Find(".tv-root-zone").ContextMenu();
|
|
var menu = cut.Find(".tv-root-menu");
|
|
var labels = menu.QuerySelectorAll("button.dropdown-item").Select(b => b.TextContent.Trim()).ToList();
|
|
|
|
Assert.Contains("New Folder", labels);
|
|
Assert.Contains("New Template", labels);
|
|
}
|
|
|
|
[Fact]
|
|
public void RootContextMenu_NewTemplate_NavigatesToRootCreate()
|
|
{
|
|
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template>()));
|
|
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
|
|
|
|
var nav = Services.GetRequiredService<NavigationManager>();
|
|
|
|
var cut = Render<TemplatesPage>();
|
|
|
|
cut.Find(".tv-root-zone").ContextMenu();
|
|
cut.Find(".tv-root-menu")
|
|
.QuerySelectorAll("button.dropdown-item")
|
|
.First(b => b.TextContent.Trim() == "New Template")
|
|
.Click();
|
|
|
|
// Root-level New Template navigates with no folderId query — a root create.
|
|
Assert.EndsWith("/design/templates/create", nav.Uri);
|
|
}
|
|
|
|
}
|
|
|
|
internal sealed class TestAuthStateProvider : AuthenticationStateProvider
|
|
{
|
|
private readonly ClaimsPrincipal _user;
|
|
public TestAuthStateProvider(ClaimsPrincipal user) => _user = user;
|
|
public override Task<AuthenticationState> GetAuthenticationStateAsync()
|
|
=> Task.FromResult(new AuthenticationState(_user));
|
|
}
|