feat(ui/deployment): consolidate sites/areas/instances into Topology page

Single /deployment/topology page replaces /deployment/instances (legacy URL
preserved as a secondary @page directive) and the /admin/areas* CRUD pages.
TreeView with Site → Area → Instance, V1–V7 visual guide (bi-building /
bi-diagram-3 / bi-box), always-visible empty containers, search dim, F2
inline area rename, and right-click context menus per node kind (Add Area,
Move to Area…, lifecycle actions, etc.).

Adds AreaService.MoveAreaAsync with cycle prevention, same-site enforcement,
and name-collision check at the new parent. Instance rename intentionally
out of scope — UniqueName is the site-side actor identity, requires its own
design pass.
This commit is contained in:
Joseph Doherty
2026-05-11 22:03:55 -04:00
parent b2eddd9713
commit f3386d0278
18 changed files with 1857 additions and 1122 deletions

View File

@@ -45,7 +45,7 @@ public class NavigationTests
}
[Theory]
[InlineData("Instances", "/deployment/instances")]
[InlineData("Topology", "/deployment/topology")]
[InlineData("Deployments", "/deployment/deployments")]
public async Task DeploymentNavLinks_NavigateCorrectly(string linkText, string expectedPath)
{

View File

@@ -0,0 +1,244 @@
using System.Security.Claims;
using Bunit;
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 ScadaLink.Commons.Entities.Instances;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Entities.Templates;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.Communication;
using ScadaLink.DeploymentManager;
using ScadaLink.TemplateEngine.Services;
using TopologyPage = ScadaLink.CentralUI.Components.Pages.Deployment.Topology;
namespace ScadaLink.CentralUI.Tests;
/// <summary>
/// bUnit rendering tests for the Topology page (Site → Area → Instance tree).
/// Focuses on the behavior that's specific to this page:
/// always-visible empty containers, search dimming, F2 area rename, and the
/// move-dialog destination lists.
/// </summary>
public class TopologyPageTests : 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>();
public TopologyPageTests()
{
Services.AddSingleton(_repo);
Services.AddSingleton(_siteRepo);
Services.AddSingleton(_deployRepo);
Services.AddSingleton(_pipeline);
Services.AddSingleton(_audit);
// DeploymentService has non-mockable concrete deps; instantiate them directly.
var comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
Services.AddSingleton(comms);
Services.AddSingleton(new OperationLockManager());
Services.AddSingleton(Options.Create(new DeploymentManagerOptions
{
OperationLockTimeout = TimeSpan.FromSeconds(5)
}));
Services.AddSingleton<ILogger<DeploymentService>>(NullLogger<DeploymentService>.Instance);
Services.AddScoped<DeploymentService>();
Services.AddScoped<AreaService>();
Services.AddScoped<InstanceService>();
AddTestAuth();
// TreeView persists expansion state via JS interop. Stub the calls so render doesn't throw.
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
}
private void AddTestAuth()
{
var claims = new[]
{
new Claim("Username", "tester"),
new Claim(ClaimTypes.Role, "Deployment")
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
}
private void SeedRepos(
IEnumerable<Site>? sites = null,
IEnumerable<Template>? templates = null,
IEnumerable<Instance>? instances = null,
Dictionary<int, IReadOnlyList<Area>>? areasBySite = null)
{
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(sites?.ToList() ?? new List<Site>()));
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(templates?.ToList() ?? new List<Template>()));
_repo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>(instances?.ToList() ?? new List<Instance>()));
areasBySite ??= new();
_repo.GetAreasBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(call =>
{
var sid = call.Arg<int>();
return Task.FromResult(areasBySite.TryGetValue(sid, out var list)
? list
: (IReadOnlyList<Area>)new List<Area>());
});
}
[Fact]
public void Renders_EmptyState_WhenNoSites()
{
SeedRepos();
var cut = Render<TopologyPage>();
Assert.Contains("No sites configured", cut.Markup);
}
[Fact]
public void Renders_EmptySite_AsTopLevelNode()
{
// An always-show-empty container is a hard requirement: a site with nothing
// under it must still appear so users can move/create into it.
SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } });
var cut = Render<TopologyPage>();
Assert.Contains("Plant-A", cut.Markup);
Assert.Contains("bi-building", cut.Markup);
}
private static AngleSharp.Dom.IElement? FindToggleForLabel(IRenderedComponent<TopologyPage> cut, string label) =>
cut.FindAll(".tv-row")
.FirstOrDefault(row => row.QuerySelector(".tv-label")?.TextContent == label)
?.QuerySelector(".tv-toggle");
[Fact]
public void Renders_SiteAreaInstance_Nesting()
{
var areasBySite = new Dictionary<int, IReadOnlyList<Area>>
{
[1] = new List<Area>
{
new("Line-1") { Id = 10, SiteId = 1, ParentAreaId = null }
}
};
SeedRepos(
sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
instances: new[]
{
new Instance("Pump-001") { Id = 100, SiteId = 1, AreaId = 10, State = InstanceState.NotDeployed }
},
areasBySite: areasBySite);
var cut = Render<TopologyPage>();
// Expand the site, then the area, to render the instance leaf. The
// helper scopes by the row's own label so we don't match outer rows
// whose TextContent transitively contains the inner label.
FindToggleForLabel(cut, "Plant-A")!.Click();
FindToggleForLabel(cut, "Line-1")!.Click();
Assert.Contains("Pump-001", cut.Markup);
Assert.Contains("bi-diagram-3", cut.Markup);
Assert.Contains("bi-box", cut.Markup);
Assert.Contains("NotDeployed", cut.Markup);
}
[Fact]
public void Search_DimsNonMatches_PreservesShape()
{
var areasBySite = new Dictionary<int, IReadOnlyList<Area>>
{
[1] = new List<Area>
{
new("Line-1") { Id = 10, SiteId = 1 },
new("Boilers") { Id = 11, SiteId = 1 }
}
};
SeedRepos(
sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
areasBySite: areasBySite);
var cut = Render<TopologyPage>();
FindToggleForLabel(cut, "Plant-A")!.Click();
var search = cut.Find("input[type='text']");
search.Input("Line");
// Both areas remain in the DOM (shape preserved). 'Boilers' gets the dim style.
Assert.Contains("Line-1", cut.Markup);
Assert.Contains("Boilers", cut.Markup);
var dimmedNodes = cut.FindAll("span.tv-label[style*='opacity']");
Assert.Contains(dimmedNodes, n => n.TextContent.Contains("Boilers"));
}
[Fact]
public void DoubleClick_OnAreaLabel_EntersRenameMode()
{
var areasBySite = new Dictionary<int, IReadOnlyList<Area>>
{
[1] = new List<Area> { new("Line-1") { Id = 10, SiteId = 1 } }
};
SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } }, areasBySite: areasBySite);
var cut = Render<TopologyPage>();
FindToggleForLabel(cut, "Plant-A")!.Click();
var areaLabel = cut.FindAll("span.tv-label").First(s => s.TextContent == "Line-1");
areaLabel.DoubleClick();
// Inline rename input replaces the label.
Assert.NotNull(cut.Find("input.form-control-sm.d-inline-block"));
}
[Fact]
public void InstanceRows_DoNotHaveDoubleClickRename()
{
// Instance rename is out of scope; the label should not have @ondblclick wired.
// bUnit throws MissingEventHandlerException when dispatching to an element
// that has no handler — that's the assertion: the dblclick event is not bound.
SeedRepos(
sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
instances: new[]
{
new Instance("Pump-001") { Id = 100, SiteId = 1, State = InstanceState.NotDeployed }
});
var cut = Render<TopologyPage>();
FindToggleForLabel(cut, "Plant-A")!.Click();
var instanceLabel = cut.FindAll("span.tv-label").First(s => s.TextContent == "Pump-001");
Assert.Throws<Bunit.MissingEventHandlerException>(() => instanceLabel.DoubleClick());
}
[Fact]
public void LegacyInstancesRoute_IsDeclaredOnTopologyPage()
{
// Old bookmarks to /deployment/instances must still resolve. Reflection
// check: the Topology component carries both @page directives.
var pageAttrs = typeof(TopologyPage).GetCustomAttributes(
typeof(Microsoft.AspNetCore.Components.RouteAttribute), inherit: false)
.Cast<Microsoft.AspNetCore.Components.RouteAttribute>()
.Select(a => a.Template)
.ToList();
Assert.Contains("/deployment/topology", pageAttrs);
Assert.Contains("/deployment/instances", pageAttrs);
}
}

View File

@@ -116,6 +116,157 @@ public class AreaServiceTests
_repoMock.Verify(r => r.DeleteAreaAsync(1, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task MoveArea_ToOtherArea_Succeeds()
{
// Move 'Leaf' from under 'A' to under 'B'.
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
_repoMock.Setup(r => r.GetAreaByIdAsync(2, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("B") { Id = 2, SiteId = 1 });
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new("A") { Id = 1, SiteId = 1 },
new("B") { Id = 2, SiteId = 1 },
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 }
});
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
var result = await _sut.MoveAreaAsync(3, 2, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.ParentAreaId);
_repoMock.Verify(r => r.UpdateAreaAsync(It.Is<Area>(a => a.Id == 3 && a.ParentAreaId == 2), It.IsAny<CancellationToken>()), Times.Once);
_auditMock.Verify(a => a.LogAsync("admin", "Move", "Area", "3", "Leaf", It.IsAny<object>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task MoveArea_ToSiteRoot_Succeeds()
{
// Move 'Leaf' from under 'A' to site root (null parent).
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new("A") { Id = 1, SiteId = 1 },
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 }
});
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
var result = await _sut.MoveAreaAsync(3, null, "admin");
Assert.True(result.IsSuccess);
Assert.Null(result.Value.ParentAreaId);
}
[Fact]
public async Task MoveArea_ToSelf_Fails()
{
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("A") { Id = 1, SiteId = 1 });
var result = await _sut.MoveAreaAsync(1, 1, "admin");
Assert.True(result.IsFailure);
Assert.Contains("its own parent", result.Error);
}
[Fact]
public async Task MoveArea_ToDescendant_FailsWithCycleError()
{
// Tree: 1 -> 2 -> 3. Try to move 1 under 3.
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Root") { Id = 1, SiteId = 1 });
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 2 });
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new("Root") { Id = 1, SiteId = 1 },
new("Mid") { Id = 2, SiteId = 1, ParentAreaId = 1 },
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 2 }
});
var result = await _sut.MoveAreaAsync(1, 3, "admin");
Assert.True(result.IsFailure);
Assert.Contains("descendants", result.Error);
}
[Fact]
public async Task MoveArea_DifferentSite_Fails()
{
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("A") { Id = 1, SiteId = 1 });
_repoMock.Setup(r => r.GetAreaByIdAsync(99, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Foreign") { Id = 99, SiteId = 2 });
var result = await _sut.MoveAreaAsync(1, 99, "admin");
Assert.True(result.IsFailure);
Assert.Contains("same site", result.Error);
}
[Fact]
public async Task MoveArea_NameCollidesAtNewParent_Fails()
{
// 'Leaf' under parent 1; a sibling 'Leaf' already exists under parent 2.
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
_repoMock.Setup(r => r.GetAreaByIdAsync(2, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("B") { Id = 2, SiteId = 1 });
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new("A") { Id = 1, SiteId = 1 },
new("B") { Id = 2, SiteId = 1 },
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 },
new("Leaf") { Id = 4, SiteId = 1, ParentAreaId = 2 }
});
var result = await _sut.MoveAreaAsync(3, 2, "admin");
Assert.True(result.IsFailure);
Assert.Contains("already exists", result.Error);
}
[Fact]
public async Task MoveArea_SameParent_NoOpSuccess()
{
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("A") { Id = 1, SiteId = 1 });
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new("A") { Id = 1, SiteId = 1 },
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 }
});
var result = await _sut.MoveAreaAsync(3, 1, "admin");
Assert.True(result.IsSuccess);
_repoMock.Verify(r => r.UpdateAreaAsync(It.IsAny<Area>(), It.IsAny<CancellationToken>()), Times.Never);
_auditMock.Verify(a => a.LogAsync(It.IsAny<string>(), "Move", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<object>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task MoveArea_TargetParentMissing_Fails()
{
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
_repoMock.Setup(r => r.GetAreaByIdAsync(999, It.IsAny<CancellationToken>()))
.ReturnsAsync((Area?)null);
var result = await _sut.MoveAreaAsync(3, 999, "admin");
Assert.True(result.IsFailure);
Assert.Contains("not found", result.Error);
}
[Fact]
public async Task DeleteArea_InstancesInDescendants_Blocked()
{