feat(transport-ui): export wizard site/instance selection (M8 E1)
This commit is contained in:
@@ -149,6 +149,17 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-sites">
|
||||
<legend class="h6">Sites & Instances</legend>
|
||||
@RenderSitesList()
|
||||
<div class="alert alert-info small mt-2 mb-0 py-2" role="alert" data-testid="sites-hint">
|
||||
Selecting a <strong>site</strong> includes its data connections and all of its
|
||||
instances. Expand a site to pick individual <strong>instances</strong> instead.
|
||||
Each instance pulls in its template (and that template's dependency closure)
|
||||
when <em>Include all dependencies</em> is on.
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2 mt-4">
|
||||
<button class="btn btn-primary"
|
||||
disabled="@(!HasAnySelection || _resolving)"
|
||||
@@ -200,6 +211,80 @@
|
||||
</div>
|
||||
};
|
||||
|
||||
// Sites & Instances picker (M8 E1): a flat site checkbox list, each row
|
||||
// expandable to reveal its instances. The search box matches a site by its
|
||||
// Name or SiteIdentifier, and an instance by its UniqueName; a site stays
|
||||
// visible when any of its instances match so the operator can drill in.
|
||||
private RenderFragment RenderSitesList() => __builder =>
|
||||
{
|
||||
var visibleSites = _sites
|
||||
.Where(site => MatchesFilter(site.Name)
|
||||
|| MatchesFilter(site.SiteIdentifier)
|
||||
|| InstancesFor(site.Id).Any(i => MatchesFilter(i.UniqueName)))
|
||||
.ToList();
|
||||
|
||||
if (visibleSites.Count == 0)
|
||||
{
|
||||
<div class="text-muted small fst-italic">@(_sites.Count == 0 ? "No sites." : "No matches.")</div>
|
||||
return;
|
||||
}
|
||||
|
||||
<div class="d-flex flex-column gap-1">
|
||||
@foreach (var site in visibleSites)
|
||||
{
|
||||
var siteInputId = $"chk-site-{site.Id}";
|
||||
var expanded = _expandedSites.Contains(site.Id);
|
||||
var instances = InstancesFor(site.Id);
|
||||
<div class="border rounded p-2" data-testid="site-row" data-site-id="@site.Id">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
@if (instances.Count > 0)
|
||||
{
|
||||
<button type="button" class="btn btn-sm btn-link p-0 text-decoration-none"
|
||||
aria-expanded="@expanded.ToString().ToLowerInvariant()"
|
||||
aria-label="@(expanded ? "Collapse" : "Expand") instances for @site.Name"
|
||||
@onclick="() => ToggleSiteExpanded(site.Id)">
|
||||
<span aria-hidden="true">@(expanded ? "▾" : "▸")</span>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted" style="width: 1rem; display: inline-block;" aria-hidden="true"></span>
|
||||
}
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
id="@siteInputId"
|
||||
checked="@_selectedSites.Contains(site.Id)"
|
||||
@onchange="e => ToggleSite(site.Id, ((bool?)e.Value) == true)" />
|
||||
<label class="form-check-label" for="@siteInputId">
|
||||
@site.Name <span class="text-muted small">(@site.SiteIdentifier)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (expanded && instances.Count > 0)
|
||||
{
|
||||
<div class="ms-4 mt-2 d-flex flex-column gap-1" data-testid="site-instances">
|
||||
@foreach (var instance in instances.Where(i => MatchesFilter(i.UniqueName)
|
||||
|| MatchesFilter(site.Name) || MatchesFilter(site.SiteIdentifier)))
|
||||
{
|
||||
var instInputId = $"chk-instance-{instance.Id}";
|
||||
var siteSelected = _selectedSites.Contains(site.Id);
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
id="@instInputId"
|
||||
disabled="@siteSelected"
|
||||
checked="@(siteSelected || _selectedInstances.Contains(instance.Id))"
|
||||
@onchange="e => Toggle(_selectedInstances, instance.Id, ((bool?)e.Value) == true)" />
|
||||
<label class="form-check-label" for="@instInputId">@instance.UniqueName</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Step 2 — Review
|
||||
// ============================================================
|
||||
@@ -214,10 +299,17 @@
|
||||
var seedTemplateIds = new HashSet<int>(SelectedTemplateIds());
|
||||
var seedSharedScripts = new HashSet<int>(_selectedSharedScripts);
|
||||
var seedExternalSystems = new HashSet<int>(_selectedExternalSystems);
|
||||
// M8 (E1): sites/instances the operator ticked directly are "seed"; everything
|
||||
// else the resolver pulled into Sites/Instances (e.g. an instance's owning site,
|
||||
// or a site's instances) is auto-included.
|
||||
var seedSiteIds = new HashSet<int>(_selectedSites);
|
||||
var seedInstanceIds = new HashSet<int>(_selectedInstances);
|
||||
|
||||
var autoTemplates = AutoIncluded(_resolved.Templates, seedTemplateIds, t => t.Id);
|
||||
var autoShared = AutoIncluded(_resolved.SharedScripts, seedSharedScripts, s => s.Id);
|
||||
var autoExternals = AutoIncluded(_resolved.ExternalSystems, seedExternalSystems, e => e.Id);
|
||||
var autoSites = AutoIncluded(_resolved.Sites, seedSiteIds, s => s.Id);
|
||||
var autoInstances = AutoIncluded(_resolved.Instances, seedInstanceIds, i => i.Id);
|
||||
|
||||
<div>
|
||||
<p class="text-body-secondary">
|
||||
@@ -267,11 +359,21 @@
|
||||
{
|
||||
<li>ApiMethod: @m.Name</li>
|
||||
}
|
||||
@foreach (var s in _resolved.Sites.Where(s => seedSiteIds.Contains(s.Id)).OrderBy(s => s.SiteIdentifier, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>Site: @s.Name (@s.SiteIdentifier)</li>
|
||||
}
|
||||
@foreach (var i in _resolved.Instances.Where(i => seedInstanceIds.Contains(i.Id)).OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>Instance: @i.UniqueName</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6" data-testid="auto-group">
|
||||
<h6>Auto-included (dependencies)</h6>
|
||||
@if (autoTemplates.Count + autoShared.Count + autoExternals.Count + _resolved.TemplateFolders.Count == 0)
|
||||
@if (autoTemplates.Count + autoShared.Count + autoExternals.Count
|
||||
+ autoSites.Count + autoInstances.Count + _resolved.DataConnections.Count
|
||||
+ _resolved.TemplateFolders.Count == 0)
|
||||
{
|
||||
<div class="small text-muted fst-italic">No additional dependencies.</div>
|
||||
}
|
||||
@@ -294,6 +396,18 @@
|
||||
{
|
||||
<li>ExternalSystem: @e.Name</li>
|
||||
}
|
||||
@foreach (var s in autoSites.OrderBy(s => s.SiteIdentifier, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>Site: @s.Name (@s.SiteIdentifier)</li>
|
||||
}
|
||||
@foreach (var i in autoInstances.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>Instance: @i.UniqueName</li>
|
||||
}
|
||||
@foreach (var c in _resolved.DataConnections.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>DataConnection: @c.Name</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
|
||||
+82
-2
@@ -6,8 +6,10 @@ using Microsoft.JSInterop;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
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;
|
||||
@@ -51,6 +53,7 @@ public partial class TransportExport : ComponentBase
|
||||
[Inject] private IExternalSystemRepository ExternalRepo { get; set; } = default!;
|
||||
[Inject] private INotificationRepository NotificationRepo { get; set; } = default!;
|
||||
[Inject] private IInboundApiRepository InboundApiRepo { get; set; } = default!;
|
||||
[Inject] private ISiteRepository SiteRepo { get; set; } = default!;
|
||||
[Inject] private DependencyResolver DepResolver { get; set; } = default!;
|
||||
[Inject] private IJSRuntime JS { get; set; } = default!;
|
||||
[Inject] private AuthenticationStateProvider Auth { get; set; } = default!;
|
||||
@@ -71,6 +74,11 @@ public partial class TransportExport : ComponentBase
|
||||
private List<SmtpConfiguration> _smtpConfigs = new();
|
||||
// Inbound API keys are not transported between environments (re-arch C4); only methods.
|
||||
private List<ApiMethod> _apiMethods = new();
|
||||
// M8 (E1): site/instance-scoped export. Sites are listed flat; each site's
|
||||
// instances hang off it (loaded eagerly via ISiteRepository.GetInstancesBySiteIdAsync)
|
||||
// so the operator can pick a whole site or drill into individual instances.
|
||||
private List<Site> _sites = new();
|
||||
private readonly Dictionary<int, List<Instance>> _instancesBySiteId = new();
|
||||
|
||||
// ---- Step 1: selection state ----
|
||||
// TemplateFolderTree uses string keys ("t:{id}", "f:{id}") via TemplateTreeNode.Key.
|
||||
@@ -84,6 +92,12 @@ public partial class TransportExport : ComponentBase
|
||||
private readonly HashSet<int> _selectedSmtpConfigs = new();
|
||||
// No _selectedApiKeys: inbound API keys are not transported (re-arch C4).
|
||||
private readonly HashSet<int> _selectedApiMethods = new();
|
||||
// M8 (E1): site/instance selection backed by entity primary keys, matching the
|
||||
// DependencyResolver's SiteIds/InstanceIds (NOT names — the resolver fetches by id).
|
||||
private readonly HashSet<int> _selectedSites = new();
|
||||
private readonly HashSet<int> _selectedInstances = new();
|
||||
// Which site rows are expanded to reveal their instance checkboxes (UI-only).
|
||||
private readonly HashSet<int> _expandedSites = new();
|
||||
private string _filter = string.Empty;
|
||||
private bool _includeDependencies = true;
|
||||
|
||||
@@ -126,6 +140,22 @@ public partial class TransportExport : ComponentBase
|
||||
_smtpConfigs = (await NotificationRepo.GetAllSmtpConfigurationsAsync()).ToList();
|
||||
// Inbound API keys are not transported (re-arch C4) — only methods are loaded.
|
||||
_apiMethods = (await InboundApiRepo.GetAllApiMethodsAsync()).ToList();
|
||||
|
||||
// M8 (E1): sites + their instances for site/instance-scoped export. Each
|
||||
// site's instances are loaded eagerly so the expandable picker has them
|
||||
// without a per-click round-trip; sites are ordered by identifier to match
|
||||
// the bundle's deterministic site ordering.
|
||||
_sites = (await SiteRepo.GetAllSitesAsync())
|
||||
.OrderBy(s => s.SiteIdentifier, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
_instancesBySiteId.Clear();
|
||||
foreach (var site in _sites)
|
||||
{
|
||||
var instances = (await SiteRepo.GetInstancesBySiteIdAsync(site.Id))
|
||||
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
_instancesBySiteId[site.Id] = instances;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -169,7 +199,9 @@ public partial class TransportExport : ComponentBase
|
||||
|| _selectedDbConnections.Count > 0
|
||||
|| _selectedNotificationLists.Count > 0
|
||||
|| _selectedSmtpConfigs.Count > 0
|
||||
|| _selectedApiMethods.Count > 0;
|
||||
|| _selectedApiMethods.Count > 0
|
||||
|| _selectedSites.Count > 0
|
||||
|| _selectedInstances.Count > 0;
|
||||
|
||||
private bool PassphraseValid =>
|
||||
!string.IsNullOrEmpty(_passphrase)
|
||||
@@ -207,7 +239,15 @@ public partial class TransportExport : ComponentBase
|
||||
SmtpConfigurationIds: _selectedSmtpConfigs.ToList(),
|
||||
// Inbound API keys are not transported (re-arch C4) — methods only.
|
||||
ApiMethodIds: _selectedApiMethods.ToList(),
|
||||
IncludeDependencies: _includeDependencies);
|
||||
IncludeDependencies: _includeDependencies)
|
||||
{
|
||||
// M8 (E1): site/instance ids feed the resolver's SiteIds/InstanceIds.
|
||||
// Set via init-only properties so the positional ctor stays the documented
|
||||
// additive shape; the resolver dedups a selected instance against its
|
||||
// already-selected owning site.
|
||||
SiteIds = _selectedSites.ToList(),
|
||||
InstanceIds = _selectedInstances.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
private async Task GoToReviewAsync()
|
||||
@@ -396,6 +436,9 @@ public partial class TransportExport : ComponentBase
|
||||
_selectedNotificationLists.Clear();
|
||||
_selectedSmtpConfigs.Clear();
|
||||
_selectedApiMethods.Clear();
|
||||
_selectedSites.Clear();
|
||||
_selectedInstances.Clear();
|
||||
_expandedSites.Clear();
|
||||
_filter = string.Empty;
|
||||
_includeDependencies = true;
|
||||
_resolved = null;
|
||||
@@ -420,6 +463,43 @@ public partial class TransportExport : ComponentBase
|
||||
else set.Remove(id);
|
||||
}
|
||||
|
||||
// ---- Step 1 site/instance helpers (M8 E1) ----
|
||||
|
||||
/// <summary>Instances loaded for a site, or an empty list when the site has none.</summary>
|
||||
private IReadOnlyList<Instance> InstancesFor(int siteId) =>
|
||||
_instancesBySiteId.TryGetValue(siteId, out var list) ? list : Array.Empty<Instance>();
|
||||
|
||||
/// <summary>Expand/collapse a site row's nested instance list (UI-only state).</summary>
|
||||
private void ToggleSiteExpanded(int siteId)
|
||||
{
|
||||
if (!_expandedSites.Remove(siteId))
|
||||
{
|
||||
_expandedSites.Add(siteId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggle a whole site. Selecting a site lets the resolver pull its instances,
|
||||
/// so on select we clear any redundant per-instance ticks for that site; on
|
||||
/// deselect we leave individual instances untouched (the operator may still
|
||||
/// want a subset). Matches the resolver's dedup semantics.
|
||||
/// </summary>
|
||||
private void ToggleSite(int siteId, bool value)
|
||||
{
|
||||
if (value)
|
||||
{
|
||||
_selectedSites.Add(siteId);
|
||||
foreach (var instance in InstancesFor(siteId))
|
||||
{
|
||||
_selectedInstances.Remove(instance.Id);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedSites.Remove(siteId);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Step 2 grouping helpers ----
|
||||
|
||||
/// <summary>
|
||||
|
||||
+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