feat(transport-ui): import Map step + per-line diff view (M8 E2)

This commit is contained in:
Joseph Doherty
2026-06-18 07:21:23 -04:00
parent e67587ec93
commit c8211f6363
5 changed files with 733 additions and 4 deletions
@@ -206,6 +206,8 @@
var hasBlockers = _preview.Items.Any(i => i.Kind == ConflictKind.Blocker); var hasBlockers = _preview.Items.Any(i => i.Kind == ConflictKind.Blocker);
<div> <div>
@RenderMapSection();
<p class="text-body-secondary"> <p class="text-body-secondary">
Review each artifact in the bundle and choose how it should be applied Review each artifact in the bundle and choose how it should be applied
to this environment. Identical items are skipped automatically; new to this environment. Identical items are skipped automatically; new
@@ -245,11 +247,22 @@
</tr> </tr>
@if (item.Kind == ConflictKind.Modified && !string.IsNullOrEmpty(item.FieldDiffJson)) @if (item.Kind == ConflictKind.Modified && !string.IsNullOrEmpty(item.FieldDiffJson))
{ {
var lineDiff = TryExtractLineDiff(item.FieldDiffJson);
<tr> <tr>
<td colspan="6"> <td colspan="6">
<details> <details>
<summary class="small">Field diff</summary> <summary class="small">Field diff</summary>
<pre class="small mb-0"><code>@item.FieldDiffJson</code></pre> @if (lineDiff is not null)
{
<div class="mt-2" data-testid="code-line-diff">
<div class="small text-body-secondary mb-1">Code changes</div>
<LineDiffView LineDiff="lineDiff" />
</div>
}
else
{
<pre class="small mb-0"><code>@item.FieldDiffJson</code></pre>
}
</details> </details>
</td> </td>
</tr> </tr>
@@ -283,6 +296,123 @@
</div> </div>
}; };
// ============================================================
// Step 3 — Map sub-section (M8 E2)
// ============================================================
// Shown only when the preview references source-environment sites/connections
// the operator must resolve before import. For central-config-only bundles the
// preview carries no required mappings and this renders nothing.
private RenderFragment RenderMapSection() => __builder =>
{
if (_preview is null) return;
var hasSiteMappings = _preview.RequiredSiteMappings.Count > 0;
var hasConnMappings = _preview.RequiredConnectionMappings.Count > 0;
if (!hasSiteMappings && !hasConnMappings)
{
return;
}
<div class="card mb-4" data-testid="map-section">
<div class="card-header bg-body-tertiary">
<strong>Resolve site &amp; connection references</strong>
<span class="small text-body-secondary ms-2">
This bundle references sites/connections from its source environment.
Map each to an existing target, or create a new one.
</span>
</div>
<div class="card-body">
@if (hasSiteMappings)
{
<h6 class="small text-uppercase text-body-secondary">Sites</h6>
<table class="table table-sm align-middle mb-4" data-testid="map-sites-table">
<thead>
<tr>
<th>Source identifier</th>
<th>Source name</th>
<th>Map to target</th>
</tr>
</thead>
<tbody>
@foreach (var rsm in _preview.RequiredSiteMappings)
{
var chosen = _siteChoices.TryGetValue(rsm.SourceSiteIdentifier, out var c) ? c : CreateNewValue;
<tr data-testid="map-site-row">
<td><code>@rsm.SourceSiteIdentifier</code></td>
<td>@rsm.SourceSiteName</td>
<td>
<select class="form-select form-select-sm"
style="max-width: 22rem;"
data-testid="@($"map-site-select-{rsm.SourceSiteIdentifier}")"
value="@chosen"
@onchange="e => OnSiteChoiceChangedAsync(rsm.SourceSiteIdentifier, e.Value?.ToString())">
<option value="@CreateNewValue" selected="@(string.IsNullOrEmpty(chosen))">Create new</option>
@foreach (var site in _targetSites)
{
<option value="@site.SiteIdentifier" selected="@(chosen == site.SiteIdentifier)">
@site.Name (@site.SiteIdentifier)
</option>
}
</select>
</td>
</tr>
}
</tbody>
</table>
}
@if (hasConnMappings)
{
<h6 class="small text-uppercase text-body-secondary">Connections</h6>
@foreach (var grp in _preview.RequiredConnectionMappings.GroupBy(m => m.SourceSiteIdentifier))
{
var siteTarget = _siteChoices.TryGetValue(grp.Key, out var st) ? st : CreateNewValue;
var targetConns = ConnectionsForChosenTarget(grp.Key);
<div class="mb-3" data-testid="map-conn-group">
<div class="small text-body-secondary mb-1">
Site <code>@grp.Key</code>
@if (string.IsNullOrEmpty(siteTarget))
{
<span class="badge bg-success ms-1">new site</span>
}
</div>
<table class="table table-sm align-middle mb-0" data-testid="map-conns-table">
<thead>
<tr>
<th>Source connection</th>
<th>Map to target</th>
</tr>
</thead>
<tbody>
@foreach (var rcm in grp)
{
var key = (rcm.SourceSiteIdentifier, rcm.SourceConnectionName);
var chosenConn = _connectionChoices.TryGetValue(key, out var cc) ? cc : CreateNewValue;
<tr data-testid="map-conn-row">
<td>@rcm.SourceConnectionName</td>
<td>
<select class="form-select form-select-sm"
style="max-width: 22rem;"
data-testid="@($"map-conn-select-{rcm.SourceSiteIdentifier}-{rcm.SourceConnectionName}")"
value="@chosenConn"
@onchange="e => OnConnectionChoiceChanged(rcm.SourceSiteIdentifier, rcm.SourceConnectionName, e.Value?.ToString())">
<option value="@CreateNewValue" selected="@(string.IsNullOrEmpty(chosenConn))">Create new</option>
@foreach (var conn in targetConns)
{
<option value="@conn.Name" selected="@(chosenConn == conn.Name)">@conn.Name</option>
}
</select>
</td>
</tr>
}
</tbody>
</table>
</div>
}
}
</div>
</div>
};
private RenderFragment RenderKindBadge(ImportPreviewItem item) => __builder => private RenderFragment RenderKindBadge(ImportPreviewItem item) => __builder =>
{ {
var (cls, label) = item.Kind switch var (cls, label) = item.Kind switch
@@ -1,9 +1,12 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.Components.Forms;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
@@ -68,6 +71,10 @@ public partial class TransportImport : ComponentBase, IDisposable
[Inject] private IAuditService AuditService { get; set; } = default!; [Inject] private IAuditService AuditService { get; set; } = default!;
[Inject] private ScadaBridgeDbContext DbContext { get; set; } = default!; [Inject] private ScadaBridgeDbContext DbContext { get; set; } = default!;
// M8 E2: the Map step needs the destination environment's sites + each
// site's connections to populate the "map to existing target" dropdowns.
[Inject] private ISiteRepository SiteRepo { get; set; } = default!;
// ---- Wizard state ---- // ---- Wizard state ----
private ImportWizardStep _step = ImportWizardStep.Upload; private ImportWizardStep _step = ImportWizardStep.Upload;
private string? _errorMessage; private string? _errorMessage;
@@ -98,6 +105,27 @@ public partial class TransportImport : ComponentBase, IDisposable
// Keyed by (EntityType, Name) — matches BundleImporter.ApplyAsync's lookup. // Keyed by (EntityType, Name) — matches BundleImporter.ApplyAsync's lookup.
private Dictionary<(string EntityType, string Name), ImportResolution>? _resolutions; private Dictionary<(string EntityType, string Name), ImportResolution>? _resolutions;
// ---- Step 3 (Map sub-section, M8 E2): name mapping ----
// The sentinel dropdown value for "Create new" — empty string can't collide
// with a real SiteIdentifier / connection Name (both are non-empty).
private const string CreateNewValue = "";
// Destination environment's sites, loaded once when the preview carries any
// required mappings.
private IReadOnlyList<Site> _targetSites = Array.Empty<Site>();
// Per-target-site connections, loaded lazily and cached by SiteIdentifier.
private readonly Dictionary<string, IReadOnlyList<DataConnection>> _targetConnections = new();
// Operator's site choices, keyed by SourceSiteIdentifier. Value is the chosen
// target SiteIdentifier, or CreateNewValue ("") for "Create new". Seeded from
// each RequiredSiteMapping.AutoMatchTargetIdentifier.
private readonly Dictionary<string, string> _siteChoices = new();
// Operator's connection choices, keyed by (SourceSiteIdentifier, SourceConnectionName).
// Value is the chosen target connection Name, or CreateNewValue for "Create new".
private readonly Dictionary<(string SourceSite, string SourceConn), string> _connectionChoices = new();
// ---- Step 4: confirm ---- // ---- Step 4: confirm ----
private string _confirmEnvironmentText = string.Empty; private string _confirmEnvironmentText = string.Empty;
@@ -392,6 +420,7 @@ public partial class TransportImport : ComponentBase, IDisposable
{ {
_preview = await BundleImporter.PreviewAsync(_session.SessionId, CancellationToken.None); _preview = await BundleImporter.PreviewAsync(_session.SessionId, CancellationToken.None);
_resolutions = BuildDefaultResolutions(_preview); _resolutions = BuildDefaultResolutions(_preview);
await InitNameMappingStateAsync(_preview);
_step = ImportWizardStep.Diff; _step = ImportWizardStep.Diff;
} }
catch (Exception ex) catch (Exception ex)
@@ -400,6 +429,193 @@ public partial class TransportImport : ComponentBase, IDisposable
} }
} }
/// <summary>
/// M8 E2: prepares the Map sub-section state for a freshly loaded preview.
/// When the preview carries no required site/connection mappings this is a
/// no-op (the Map section is hidden) and we skip the site/connection reads
/// entirely — central-config-only bundles never touch the destination's
/// site catalogue.
///
/// <para>Default choices follow the reference precedence: each site/connection
/// is seeded to its <c>AutoMatch*</c> value when the importer found a match,
/// otherwise to "Create new". The operator's explicit dropdown choice then
/// wins over the auto-match default.</para>
/// </summary>
private async Task InitNameMappingStateAsync(ImportPreview preview)
{
_targetSites = Array.Empty<Site>();
_targetConnections.Clear();
_siteChoices.Clear();
_connectionChoices.Clear();
if (preview.RequiredSiteMappings.Count == 0 && preview.RequiredConnectionMappings.Count == 0)
{
return;
}
_targetSites = await SiteRepo.GetAllSitesAsync(CancellationToken.None);
foreach (var rsm in preview.RequiredSiteMappings)
{
// Seed to the auto-matched target identifier when present AND that
// target still exists in the destination; otherwise "Create new".
var seed = rsm.AutoMatchTargetIdentifier is not null
&& _targetSites.Any(s => s.SiteIdentifier == rsm.AutoMatchTargetIdentifier)
? rsm.AutoMatchTargetIdentifier
: CreateNewValue;
_siteChoices[rsm.SourceSiteIdentifier] = seed;
// Eagerly load the seeded target's connections so the connection-row
// dropdowns beneath this site can render their option sets without an
// async hop on first render.
if (!string.IsNullOrEmpty(seed) && !_targetConnections.ContainsKey(seed))
{
var target = _targetSites.First(s => s.SiteIdentifier == seed);
_targetConnections[seed] =
await SiteRepo.GetDataConnectionsBySiteIdAsync(target.Id, CancellationToken.None);
}
}
foreach (var rcm in preview.RequiredConnectionMappings)
{
// Default the connection to its auto-match. We don't pre-validate it
// against the chosen target's connection list here — the dropdown's
// option set (driven by the chosen site) governs what's selectable,
// and the seed simply renders as the initial selection.
_connectionChoices[(rcm.SourceSiteIdentifier, rcm.SourceConnectionName)] =
rcm.AutoMatchTargetName ?? CreateNewValue;
}
}
/// <summary>
/// Returns the connections of the destination site the operator chose for
/// <paramref name="sourceSiteIdentifier"/> (used to populate a connection
/// row's dropdown). Reads the <c>_targetConnections</c> cache, which is
/// populated for every chosen target site by <see cref="InitNameMappingStateAsync"/>
/// (seeded auto-matches) and <see cref="OnSiteChoiceChangedAsync"/> (operator
/// changes). Returns an empty list when the source site maps to "Create new"
/// (no existing target connections to bind to).
/// </summary>
private IReadOnlyList<DataConnection> ConnectionsForChosenTarget(string sourceSiteIdentifier)
{
if (!_siteChoices.TryGetValue(sourceSiteIdentifier, out var targetSiteIdentifier)
|| string.IsNullOrEmpty(targetSiteIdentifier))
{
return Array.Empty<DataConnection>();
}
return _targetConnections.TryGetValue(targetSiteIdentifier, out var cached)
? cached
: Array.Empty<DataConnection>();
}
/// <summary>
/// Handles a site-mapping dropdown change. Records the choice and, when a
/// concrete target site is chosen, eagerly loads + caches that site's
/// connections so the per-connection dropdowns underneath it can render
/// their option sets synchronously.
/// </summary>
private async Task OnSiteChoiceChangedAsync(string sourceSiteIdentifier, string? value)
{
var chosen = value ?? CreateNewValue;
_siteChoices[sourceSiteIdentifier] = chosen;
if (!string.IsNullOrEmpty(chosen) && !_targetConnections.ContainsKey(chosen))
{
var target = _targetSites.FirstOrDefault(s => s.SiteIdentifier == chosen);
if (target is not null)
{
_targetConnections[chosen] =
await SiteRepo.GetDataConnectionsBySiteIdAsync(target.Id, CancellationToken.None);
}
}
}
private void OnConnectionChoiceChanged(string sourceSiteIdentifier, string sourceConnectionName, string? value)
{
_connectionChoices[(sourceSiteIdentifier, sourceConnectionName)] = value ?? CreateNewValue;
}
/// <summary>
/// M8 E2: folds the operator's Map-step choices into a <see cref="BundleNameMap"/>.
/// A concrete chosen target → <see cref="MappingAction.MapToExisting"/> with that
/// target identifier/name; the "Create new" sentinel → <see cref="MappingAction.CreateNew"/>
/// with a null target. Iterates the preview's required mappings so the map's shape
/// always matches what the bundle references (a missing choice falls back to its
/// auto-match default, mirroring the seed). Returns <see cref="BundleNameMap.Empty"/>
/// when the preview carries no required mappings.
/// </summary>
private BundleNameMap BuildNameMap()
{
if (_preview is null
|| (_preview.RequiredSiteMappings.Count == 0 && _preview.RequiredConnectionMappings.Count == 0))
{
return BundleNameMap.Empty;
}
var sites = new List<SiteMapping>(_preview.RequiredSiteMappings.Count);
foreach (var rsm in _preview.RequiredSiteMappings)
{
var chosen = _siteChoices.TryGetValue(rsm.SourceSiteIdentifier, out var c)
? c
: (rsm.AutoMatchTargetIdentifier ?? CreateNewValue);
sites.Add(string.IsNullOrEmpty(chosen)
? new SiteMapping(rsm.SourceSiteIdentifier, MappingAction.CreateNew, null)
: new SiteMapping(rsm.SourceSiteIdentifier, MappingAction.MapToExisting, chosen));
}
var connections = new List<ConnectionMapping>(_preview.RequiredConnectionMappings.Count);
foreach (var rcm in _preview.RequiredConnectionMappings)
{
var key = (rcm.SourceSiteIdentifier, rcm.SourceConnectionName);
var chosen = _connectionChoices.TryGetValue(key, out var c)
? c
: (rcm.AutoMatchTargetName ?? CreateNewValue);
connections.Add(string.IsNullOrEmpty(chosen)
? new ConnectionMapping(rcm.SourceSiteIdentifier, rcm.SourceConnectionName, MappingAction.CreateNew, null)
: new ConnectionMapping(rcm.SourceSiteIdentifier, rcm.SourceConnectionName, MappingAction.MapToExisting, chosen));
}
return new BundleNameMap(sites, connections);
}
/// <summary>
/// M8 E2: parses a Modified item's <c>FieldDiffJson</c> and returns the
/// <c>lineDiff</c> object for the first code field that carries one, or null
/// when the diff has no line-level payload (ordinary fields render as a coarse
/// summary instead). Tolerant of malformed/absent JSON — a parse failure
/// simply yields null so the row degrades to the coarse field-diff view.
/// </summary>
internal static JsonElement? TryExtractLineDiff(string? fieldDiffJson)
{
if (string.IsNullOrWhiteSpace(fieldDiffJson))
{
return null;
}
try
{
using var doc = JsonDocument.Parse(fieldDiffJson);
if (!doc.RootElement.TryGetProperty("changes", out var changes)
|| changes.ValueKind != JsonValueKind.Array)
{
return null;
}
foreach (var change in changes.EnumerateArray())
{
if (change.ValueKind == JsonValueKind.Object
&& change.TryGetProperty("lineDiff", out var lineDiff)
&& lineDiff.ValueKind == JsonValueKind.Object)
{
// Clone so the element outlives the JsonDocument we dispose.
return lineDiff.Clone();
}
}
return null;
}
catch (JsonException)
{
return null;
}
}
/// <summary> /// <summary>
/// Builds the default resolution per preview item: /// Builds the default resolution per preview item:
/// <list type="bullet"> /// <list type="bullet">
@@ -527,11 +743,16 @@ public partial class TransportImport : ComponentBase, IDisposable
try try
{ {
var user = await Auth.GetCurrentUsernameAsync(); var user = await Auth.GetCurrentUsernameAsync();
// M8 E2: fold the operator's Map-step choices into the name map. For
// central-config-only bundles (no required mappings) this is
// BundleNameMap.Empty, which the importer normalises away.
var nameMap = BuildNameMap();
_result = await BundleImporter.ApplyAsync( _result = await BundleImporter.ApplyAsync(
_session.SessionId, _session.SessionId,
_resolutions.Values.ToList(), _resolutions.Values.ToList(),
user, user,
CancellationToken.None); CancellationToken.None,
nameMap: nameMap);
_step = ImportWizardStep.Result; _step = ImportWizardStep.Result;
} }
catch (SemanticValidationException ex) catch (SemanticValidationException ex)
@@ -564,6 +785,11 @@ public partial class TransportImport : ComponentBase, IDisposable
_confirmEnvironmentText = string.Empty; _confirmEnvironmentText = string.Empty;
_result = null; _result = null;
_validationErrors = null; _validationErrors = null;
// M8 E2: clear the Map sub-section state too.
_targetSites = Array.Empty<Site>();
_targetConnections.Clear();
_siteChoices.Clear();
_connectionChoices.Clear();
} }
/// <summary> /// <summary>
@@ -0,0 +1,133 @@
@using System.Text.Json
@*
LineDiffView (Component #24, Task M8 E2).
Renders a single Code field's "lineDiff" payload — the line-level diff that the
importer attaches to a Modified ImportPreviewItem's FieldDiffJson for code fields —
as a compact, GitHub-style +/- list. Pure presentation: it takes an already-parsed
JsonElement (the value of the "lineDiff" key) and walks its "hunks" array.
hunk op ∈ "context" | "add" | "remove" (lowercase):
context → muted line, both old/new line numbers
add → green-ish line, new line number only (no oldLineNo)
remove → red-ish line, old line number only (no newLineNo)
When the payload's "truncated" flag is true a trailing marker summarises the
addedCount / removedCount the diff could not show in full.
No third-party diff/charting library — Bootstrap utility classes + a small
monospace block only.
*@
@if (_hunks.Count == 0 && !_truncated)
{
<div class="text-muted small fst-italic" data-testid="line-diff-empty">No line-level changes.</div>
}
else
{
<div class="border rounded bg-body-tertiary font-monospace small overflow-auto"
style="max-height: 22rem;" data-testid="line-diff">
@foreach (var hunk in _hunks)
{
var (rowCls, gutter, sign) = hunk.Op switch
{
"add" => ("bg-success-subtle text-success-emphasis", FormatGutter(null, hunk.NewLineNo), "+"),
"remove" => ("bg-danger-subtle text-danger-emphasis", FormatGutter(hunk.OldLineNo, null), "-"),
_ => ("text-body-secondary", FormatGutter(hunk.OldLineNo, hunk.NewLineNo), " "),
};
<div class="d-flex @rowCls" data-testid="@($"line-diff-{hunk.Op}")">
<span class="px-2 text-body-tertiary text-nowrap user-select-none"
style="min-width: 6.5rem;">@gutter</span>
<span class="px-1 text-nowrap user-select-none">@sign</span>
<span class="px-2 text-break flex-grow-1" style="white-space: pre-wrap;">@hunk.Text</span>
</div>
}
</div>
@if (_truncated)
{
<div class="text-muted small fst-italic mt-1" data-testid="line-diff-truncated">
… diff truncated (+@_addedCount / -@_removedCount more)
</div>
}
}
@code {
/// <summary>
/// The parsed value of a Modified item's <c>FieldDiffJson</c> code field's
/// <c>lineDiff</c> key. When null the component renders nothing meaningful —
/// callers should only render it for code fields that actually carry a
/// <c>lineDiff</c> object.
/// </summary>
[Parameter]
public JsonElement? LineDiff { get; set; }
private readonly List<Hunk> _hunks = new();
private bool _truncated;
private int _addedCount;
private int _removedCount;
private sealed record Hunk(string Op, string Text, int? OldLineNo, int? NewLineNo);
protected override void OnParametersSet()
{
_hunks.Clear();
_truncated = false;
_addedCount = 0;
_removedCount = 0;
if (LineDiff is not { ValueKind: JsonValueKind.Object } payload)
{
return;
}
if (payload.TryGetProperty("truncated", out var truncatedEl)
&& truncatedEl.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
_truncated = truncatedEl.GetBoolean();
}
if (payload.TryGetProperty("addedCount", out var addedEl)
&& addedEl.ValueKind == JsonValueKind.Number)
{
_addedCount = addedEl.GetInt32();
}
if (payload.TryGetProperty("removedCount", out var removedEl)
&& removedEl.ValueKind == JsonValueKind.Number)
{
_removedCount = removedEl.GetInt32();
}
if (payload.TryGetProperty("hunks", out var hunksEl)
&& hunksEl.ValueKind == JsonValueKind.Array)
{
foreach (var h in hunksEl.EnumerateArray())
{
if (h.ValueKind != JsonValueKind.Object)
{
continue;
}
var op = h.TryGetProperty("op", out var opEl) && opEl.ValueKind == JsonValueKind.String
? opEl.GetString() ?? "context"
: "context";
var text = h.TryGetProperty("text", out var textEl) && textEl.ValueKind == JsonValueKind.String
? textEl.GetString() ?? string.Empty
: string.Empty;
int? oldLineNo = h.TryGetProperty("oldLineNo", out var oldEl) && oldEl.ValueKind == JsonValueKind.Number
? oldEl.GetInt32()
: null;
int? newLineNo = h.TryGetProperty("newLineNo", out var newEl) && newEl.ValueKind == JsonValueKind.Number
? newEl.GetInt32()
: null;
_hunks.Add(new Hunk(op, text, oldLineNo, newLineNo));
}
}
}
private static string FormatGutter(int? oldLineNo, int? newLineNo)
{
var left = oldLineNo?.ToString() ?? string.Empty;
var right = newLineNo?.ToString() ?? string.Empty;
return $"{left,3} {right,3}";
}
}
@@ -9,6 +9,8 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NSubstitute; using NSubstitute;
using NSubstitute.ExceptionExtensions; using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
@@ -37,6 +39,10 @@ public class TransportImportPageTests : BunitContext
{ {
private readonly IBundleImporter _importer = Substitute.For<IBundleImporter>(); private readonly IBundleImporter _importer = Substitute.For<IBundleImporter>();
private readonly IAuditService _auditService = Substitute.For<IAuditService>(); private readonly IAuditService _auditService = Substitute.For<IAuditService>();
// M8 E2: the Map step injects ISiteRepository to populate target-site +
// target-connection dropdowns. Register a substitute so every test (not just
// the Map tests) can render the wizard without a missing-service failure.
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
public TransportImportPageTests() public TransportImportPageTests()
{ {
@@ -44,6 +50,12 @@ public class TransportImportPageTests : BunitContext
Services.AddSingleton(_importer); Services.AddSingleton(_importer);
Services.AddSingleton(_auditService); Services.AddSingleton(_auditService);
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Array.Empty<Site>());
_siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Array.Empty<DataConnection>());
Services.AddSingleton(_siteRepo);
Services.AddSingleton<IOptions<TransportOptions>>( Services.AddSingleton<IOptions<TransportOptions>>(
Microsoft.Extensions.Options.Options.Create(new TransportOptions Microsoft.Extensions.Options.Options.Create(new TransportOptions
{ {
@@ -248,7 +260,8 @@ public class TransportImportPageTests : BunitContext
session.SessionId, session.SessionId,
Arg.Any<IReadOnlyList<ImportResolution>>(), Arg.Any<IReadOnlyList<ImportResolution>>(),
"alice", "alice",
Arg.Any<CancellationToken>()) Arg.Any<CancellationToken>(),
Arg.Any<BundleNameMap>())
.Returns(expectedResult); .Returns(expectedResult);
var cut = Render<TransportImportPage>(); var cut = Render<TransportImportPage>();
@@ -275,7 +288,8 @@ public class TransportImportPageTests : BunitContext
rs.Any(r => r.EntityType == "Template" && r.Name == "Pump" rs.Any(r => r.EntityType == "Template" && r.Name == "Pump"
&& r.Action == ResolutionAction.Overwrite)), && r.Action == ResolutionAction.Overwrite)),
"alice", "alice",
Arg.Any<CancellationToken>()); Arg.Any<CancellationToken>(),
Arg.Any<BundleNameMap>());
Assert.Equal( Assert.Equal(
TransportImportPage.ImportWizardStep.Result, TransportImportPage.ImportWizardStep.Result,
@@ -329,6 +343,230 @@ public class TransportImportPageTests : BunitContext
Assert.Equal(ResolutionAction.Skip, map[("Reference", "D")].Action); Assert.Equal(ResolutionAction.Skip, map[("Reference", "D")].Action);
} }
// ─────────────────────────────────────────────────────────────────────
// Test 8 (M8 E2): the Map sub-section renders one row per required site /
// connection mapping with the auto-match defaults pre-selected, and the
// section is hidden entirely when the preview carries no required mappings.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Map_section_renders_required_rows_with_automatch_defaults()
{
// Target environment has one site (site-b) with one connection (opc-main),
// which the bundle's source site "site-a" / connection "opc-a" auto-match to.
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Site> { new("Site B", "site-b") { Id = 7 } });
_siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any<CancellationToken>())
.Returns(new List<DataConnection> { new("opc-main", "OpcUa", 7) { Id = 11 } });
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
var preview = new ImportPreview(
session.SessionId,
new List<ImportPreviewItem>(),
new List<RequiredSiteMapping> { new("site-a", "Site A", "site-b") },
new List<RequiredConnectionMapping> { new("site-a", "opc-a", "opc-main") });
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>()).Returns(preview);
var cut = Render<TransportImportPage>();
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
await cut.InvokeAsync(async () =>
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
cut.Render();
// Section present with one site row and one connection row.
Assert.NotNull(cut.Find("[data-testid='map-section']"));
Assert.Single(cut.FindAll("[data-testid='map-site-row']"));
Assert.Single(cut.FindAll("[data-testid='map-conn-row']"));
// Auto-match defaults: site-a → site-b, opc-a → opc-main.
var siteSelect = cut.Find("[data-testid='map-site-select-site-a']");
var selectedSiteOpt = siteSelect.QuerySelectorAll("option").Single(o => o.HasAttribute("selected"));
Assert.Equal("site-b", selectedSiteOpt.GetAttribute("value"));
var connSelect = cut.Find("[data-testid='map-conn-select-site-a-opc-a']");
var selectedConnOpt = connSelect.QuerySelectorAll("option").Single(o => o.HasAttribute("selected"));
Assert.Equal("opc-main", selectedConnOpt.GetAttribute("value"));
}
[Fact]
public async Task Map_section_hidden_when_no_required_mappings()
{
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
var preview = new ImportPreview(session.SessionId, new List<ImportPreviewItem>
{
new("Template", "Pump", null, 1, ConflictKind.New, null, null),
});
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>()).Returns(preview);
var cut = Render<TransportImportPage>();
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
await cut.InvokeAsync(async () =>
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
cut.Render();
Assert.Empty(cut.FindAll("[data-testid='map-section']"));
}
// ─────────────────────────────────────────────────────────────────────
// Test 9 (M8 E2): the operator's Map choices fold into the BundleNameMap
// passed to ApplyAsync — a chosen target → MapToExisting, "Create new" →
// CreateNew. Captured via the substituted IBundleImporter.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Apply_passes_BundleNameMap_built_from_map_choices()
{
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Site> { new("Site B", "site-b") { Id = 7 } });
_siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any<CancellationToken>())
.Returns(new List<DataConnection> { new("opc-main", "OpcUa", 7) { Id = 11 } });
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
var preview = new ImportPreview(
session.SessionId,
new List<ImportPreviewItem>(),
// site-a auto-matches site-b; site-c has no auto-match (→ Create new default).
new List<RequiredSiteMapping>
{
new("site-a", "Site A", "site-b"),
new("site-c", "Site C", null),
},
// opc-a (under site-a) auto-matches opc-main.
new List<RequiredConnectionMapping> { new("site-a", "opc-a", "opc-main") });
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>()).Returns(preview);
var expectedResult = new ImportResult(
Guid.NewGuid(), 0, 0, 0, 0, Array.Empty<int>(), Guid.NewGuid().ToString());
_importer.ApplyAsync(
session.SessionId,
Arg.Any<IReadOnlyList<ImportResolution>>(),
"alice",
Arg.Any<CancellationToken>(),
Arg.Any<BundleNameMap>())
.Returns(expectedResult);
var cut = Render<TransportImportPage>();
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
await cut.InvokeAsync(async () =>
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
await cut.InvokeAsync(() => SetField(cut.Instance, "_confirmEnvironmentText", "prod-cluster"));
await cut.InvokeAsync(async () => await InvokeAsyncMethod(cut.Instance, "ApplyAsync"));
await _importer.Received(1).ApplyAsync(
session.SessionId,
Arg.Any<IReadOnlyList<ImportResolution>>(),
"alice",
Arg.Any<CancellationToken>(),
Arg.Is<BundleNameMap>(m =>
m.Sites.Count == 2
&& m.Sites.Any(s => s.SourceSiteIdentifier == "site-a"
&& s.Action == MappingAction.MapToExisting && s.TargetSiteIdentifier == "site-b")
&& m.Sites.Any(s => s.SourceSiteIdentifier == "site-c"
&& s.Action == MappingAction.CreateNew && s.TargetSiteIdentifier == null)
&& m.Connections.Count == 1
&& m.Connections.Any(c => c.SourceSiteIdentifier == "site-a"
&& c.SourceConnectionName == "opc-a"
&& c.Action == MappingAction.MapToExisting && c.TargetConnectionName == "opc-main")));
}
// ─────────────────────────────────────────────────────────────────────
// Test 10 (M8 E2): a Modified row whose FieldDiffJson carries a code
// lineDiff renders +/- lines via LineDiffView.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Modified_row_with_code_lineDiff_renders_add_and_remove_lines()
{
const string fieldDiffJson = """
{
"changes": [
{
"field": "Code",
"oldValue": "a\nb\nc",
"newValue": "a\nB\nc",
"lineDiff": {
"hunks": [
{ "op": "context", "text": "a", "oldLineNo": 1, "newLineNo": 1 },
{ "op": "remove", "text": "b", "oldLineNo": 2 },
{ "op": "add", "text": "B", "newLineNo": 2 },
{ "op": "context", "text": "c", "oldLineNo": 3, "newLineNo": 3 }
],
"truncated": false,
"addedCount": 1,
"removedCount": 1
}
}
]
}
""";
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
var preview = new ImportPreview(session.SessionId, new List<ImportPreviewItem>
{
new("Template", "Pump", 1, 2, ConflictKind.Modified, fieldDiffJson, null),
});
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>()).Returns(preview);
var cut = Render<TransportImportPage>();
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
await cut.InvokeAsync(async () =>
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
cut.Render();
// The code line-diff block is present and shows one add + one remove line.
Assert.NotNull(cut.Find("[data-testid='code-line-diff']"));
Assert.NotNull(cut.Find("[data-testid='line-diff']"));
Assert.Single(cut.FindAll("[data-testid='line-diff-add']"));
Assert.Single(cut.FindAll("[data-testid='line-diff-remove']"));
// The raw JSON <pre> fallback is NOT used for code-field diffs.
Assert.DoesNotContain("\"lineDiff\"", cut.Find("[data-testid='code-line-diff']").InnerHtml);
// No truncation marker for a complete diff.
Assert.Empty(cut.FindAll("[data-testid='line-diff-truncated']"));
}
// ─────────────────────────────────────────────────────────────────────
// Test 11 (M8 E2): truncation marker shows when the lineDiff is truncated.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Modified_row_with_truncated_lineDiff_shows_truncation_marker()
{
const string fieldDiffJson = """
{
"changes": [
{
"field": "Code",
"oldValue": "x",
"newValue": "y",
"lineDiff": {
"hunks": [
{ "op": "remove", "text": "x", "oldLineNo": 1 },
{ "op": "add", "text": "y", "newLineNo": 1 }
],
"truncated": true,
"addedCount": 12,
"removedCount": 8
}
}
]
}
""";
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
var preview = new ImportPreview(session.SessionId, new List<ImportPreviewItem>
{
new("Template", "Pump", 1, 2, ConflictKind.Modified, fieldDiffJson, null),
});
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>()).Returns(preview);
var cut = Render<TransportImportPage>();
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
await cut.InvokeAsync(async () =>
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
cut.Render();
var marker = cut.Find("[data-testid='line-diff-truncated']");
Assert.Contains("truncated", marker.TextContent);
Assert.Contains("+12", marker.TextContent);
Assert.Contains("-8", marker.TextContent);
}
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
// Reflection helpers — the wizard's per-instance state is private (the // Reflection helpers — the wizard's per-instance state is private (the
// razor partial pattern). We poke at it via reflection rather than // razor partial pattern). We poke at it via reflection rather than
@@ -222,6 +222,8 @@ public sealed class QueryStringDrillInTests
var importer = Substitute.For<IBundleImporter>(); var importer = Substitute.For<IBundleImporter>();
Services.AddSingleton(importer); Services.AddSingleton(importer);
Services.AddSingleton(Substitute.For<IAuditService>()); Services.AddSingleton(Substitute.For<IAuditService>());
// M8 E2: TransportImport's Map step injects ISiteRepository.
Services.AddSingleton(Substitute.For<ISiteRepository>());
Services.AddSingleton<IOptions<TransportOptions>>( Services.AddSingleton<IOptions<TransportOptions>>(
Microsoft.Extensions.Options.Options.Create(new TransportOptions Microsoft.Extensions.Options.Options.Create(new TransportOptions
{ {