feat(transport-ui): export wizard site/instance selection (M8 E1)
This commit is contained in:
+117
-2
@@ -7,8 +7,10 @@ using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
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.Transport;
|
||||
@@ -49,6 +51,7 @@ public class TransportExportPageTests : BunitContext
|
||||
private readonly IExternalSystemRepository _externalRepo = Substitute.For<IExternalSystemRepository>();
|
||||
private readonly INotificationRepository _notificationRepo = Substitute.For<INotificationRepository>();
|
||||
private readonly IInboundApiRepository _inboundApiRepo = Substitute.For<IInboundApiRepository>();
|
||||
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||
private readonly IBundleExporter _exporter = Substitute.For<IBundleExporter>();
|
||||
|
||||
public TransportExportPageTests()
|
||||
@@ -73,14 +76,24 @@ public class TransportExportPageTests : BunitContext
|
||||
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(new List<SmtpConfiguration>()));
|
||||
_inboundApiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod>()));
|
||||
// Empty site/instance defaults — the M8 site/instance picker calls these on load
|
||||
// and the DependencyResolver fans out over sites on resolve.
|
||||
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>()));
|
||||
_siteRepo.GetInstancesBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance>()));
|
||||
_siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<DataConnection>>(new List<DataConnection>()));
|
||||
|
||||
Services.AddSingleton(_templateRepo);
|
||||
Services.AddSingleton(_externalRepo);
|
||||
Services.AddSingleton(_notificationRepo);
|
||||
Services.AddSingleton(_inboundApiRepo);
|
||||
Services.AddSingleton(_siteRepo);
|
||||
Services.AddSingleton(_exporter);
|
||||
// DependencyResolver is sealed but its only dependencies are the four
|
||||
// repositories above — registering the concrete type is enough.
|
||||
// DependencyResolver is sealed but its dependencies are the repositories above
|
||||
// (template/external/notification/inbound + ISiteRepository for M8 site/instance
|
||||
// export) — registering the concrete type is enough.
|
||||
Services.AddSingleton<DependencyResolver>();
|
||||
Services.AddSingleton<IOptions<TransportOptions>>(
|
||||
Microsoft.Extensions.Options.Options.Create(new TransportOptions
|
||||
@@ -312,6 +325,108 @@ public class TransportExportPageTests : BunitContext
|
||||
Assert.False(result.Succeeded);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Test 5 (M8 E1): Step 1 renders the Sites & Instances group, listing each
|
||||
// site and (when expanded) its instances.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Renders_step1_sites_group_with_sites_and_instances()
|
||||
{
|
||||
var site = new Site("North Plant", "north") { Id = 5 };
|
||||
var instance = new Instance("north/pump-01") { Id = 50, SiteId = 5 };
|
||||
|
||||
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site> { site }));
|
||||
_siteRepo.GetInstancesBySiteIdAsync(5, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance> { instance }));
|
||||
|
||||
var cut = Render<TransportExportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("North Plant"));
|
||||
|
||||
// The Sites & Instances group is present alongside the existing artifact groups.
|
||||
Assert.NotNull(cut.Find("[data-testid='group-sites']"));
|
||||
Assert.NotNull(cut.Find("[data-testid='sites-hint']"));
|
||||
Assert.Contains("North Plant", cut.Markup);
|
||||
Assert.Contains("north", cut.Markup);
|
||||
|
||||
// The site row carries a site checkbox; expanding reveals the instance checkbox.
|
||||
Assert.NotNull(cut.Find("#chk-site-5"));
|
||||
var expandToggle = cut.Find("[data-testid='site-row'] button");
|
||||
expandToggle.Click();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.NotNull(cut.Find("[data-testid='site-instances']"));
|
||||
Assert.NotNull(cut.Find("#chk-instance-50"));
|
||||
Assert.Contains("north/pump-01", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Test 6 (M8 E1): Selecting a site flows its id into ExportSelection.SiteIds;
|
||||
// selecting an individual instance flows its id into InstanceIds. Verified at
|
||||
// the resolver/exporter boundary (the same contract DependencyResolver reads).
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task Step4_export_carries_selected_site_and_instance_ids()
|
||||
{
|
||||
var siteA = new Site("North Plant", "north") { Id = 5 };
|
||||
var siteB = new Site("South Plant", "south") { Id = 6 };
|
||||
var instanceB = new Instance("south/pump-09") { Id = 90, SiteId = 6 };
|
||||
|
||||
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site> { siteA, siteB }));
|
||||
_siteRepo.GetInstancesBySiteIdAsync(5, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance>()));
|
||||
_siteRepo.GetInstancesBySiteIdAsync(6, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance> { instanceB }));
|
||||
// Resolver fetches selected entities by id; return them so the closure is non-empty.
|
||||
_siteRepo.GetSiteByIdAsync(5, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<Site?>(siteA));
|
||||
|
||||
_exporter
|
||||
.ExportAsync(
|
||||
Arg.Any<ExportSelection>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string?>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(_ => Task.FromResult<Stream>(new MemoryStream(new byte[] { 0x50, 0x4b, 0x03, 0x04 })));
|
||||
|
||||
var cut = Render<TransportExportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("North Plant"));
|
||||
|
||||
// Tick site 5 (North Plant) directly.
|
||||
cut.Find("#chk-site-5").Change(true);
|
||||
|
||||
// Expand South Plant and tick its instance individually.
|
||||
var southRow = cut.FindAll("[data-testid='site-row']")
|
||||
.First(r => r.GetAttribute("data-site-id") == "6");
|
||||
southRow.QuerySelector("button")!.Click();
|
||||
cut.WaitForState(() => cut.FindAll("#chk-instance-90").Count > 0);
|
||||
cut.Find("#chk-instance-90").Change(true);
|
||||
|
||||
// Step 1 → 2 → 3 → export.
|
||||
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new());
|
||||
cut.WaitForAssertion(() => Assert.Contains("Selected by you", cut.Markup));
|
||||
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new());
|
||||
cut.WaitForAssertion(() => Assert.Contains("Passphrase", cut.Markup));
|
||||
|
||||
cut.Find("#passphrase").Input("hunter2hunter2");
|
||||
cut.Find("#passphrase-confirm").Input("hunter2hunter2");
|
||||
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Export").ClickAsync(new());
|
||||
cut.WaitForAssertion(() => Assert.Contains("Bundle ready", cut.Markup));
|
||||
|
||||
await _exporter.Received(1).ExportAsync(
|
||||
Arg.Is<ExportSelection>(s =>
|
||||
s.SiteIds.Contains(5)
|
||||
&& s.InstanceIds.Contains(90)),
|
||||
"alice",
|
||||
"test-cluster",
|
||||
"hunter2hunter2",
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Static helpers — exercised directly so the file-naming + secret-count
|
||||
// contract is unit-pinned independently of the rendering surface.
|
||||
|
||||
Reference in New Issue
Block a user