diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor index 2c24a379..8e875109 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor @@ -206,6 +206,8 @@ var hasBlockers = _preview.Items.Any(i => i.Kind == ConflictKind.Blocker);
+ @RenderMapSection(); +

Review each artifact in the bundle and choose how it should be applied to this environment. Identical items are skipped automatically; new @@ -245,11 +247,22 @@ @if (item.Kind == ConflictKind.Modified && !string.IsNullOrEmpty(item.FieldDiffJson)) { + var lineDiff = TryExtractLineDiff(item.FieldDiffJson);

Field diff -
@item.FieldDiffJson
+ @if (lineDiff is not null) + { +
+
Code changes
+ +
+ } + else + { +
@item.FieldDiffJson
+ }
@@ -283,6 +296,123 @@
}; + // ============================================================ + // 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; + } + +
+
+ Resolve site & connection references + + This bundle references sites/connections from its source environment. + Map each to an existing target, or create a new one. + +
+
+ @if (hasSiteMappings) + { +
Sites
+ + + + + + + + + + @foreach (var rsm in _preview.RequiredSiteMappings) + { + var chosen = _siteChoices.TryGetValue(rsm.SourceSiteIdentifier, out var c) ? c : CreateNewValue; + + + + + + } + +
Source identifierSource nameMap to target
@rsm.SourceSiteIdentifier@rsm.SourceSiteName + +
+ } + + @if (hasConnMappings) + { +
Connections
+ @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); +
+
+ Site @grp.Key + @if (string.IsNullOrEmpty(siteTarget)) + { + new site + } +
+ + + + + + + + + @foreach (var rcm in grp) + { + var key = (rcm.SourceSiteIdentifier, rcm.SourceConnectionName); + var chosenConn = _connectionChoices.TryGetValue(key, out var cc) ? cc : CreateNewValue; + + + + + } + +
Source connectionMap to target
@rcm.SourceConnectionName + +
+
+ } + } +
+
+ }; + private RenderFragment RenderKindBadge(ImportPreviewItem item) => __builder => { var (cls, label) = item.Kind switch diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor.cs index dc709157..529b325f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor.cs @@ -1,9 +1,12 @@ using System.Security.Cryptography; +using System.Text.Json; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Forms; using Microsoft.Extensions.Options; 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.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 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 ---- private ImportWizardStep _step = ImportWizardStep.Upload; private string? _errorMessage; @@ -98,6 +105,27 @@ public partial class TransportImport : ComponentBase, IDisposable // Keyed by (EntityType, Name) — matches BundleImporter.ApplyAsync's lookup. 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 _targetSites = Array.Empty(); + + // Per-target-site connections, loaded lazily and cached by SiteIdentifier. + private readonly Dictionary> _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 _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 ---- private string _confirmEnvironmentText = string.Empty; @@ -392,6 +420,7 @@ public partial class TransportImport : ComponentBase, IDisposable { _preview = await BundleImporter.PreviewAsync(_session.SessionId, CancellationToken.None); _resolutions = BuildDefaultResolutions(_preview); + await InitNameMappingStateAsync(_preview); _step = ImportWizardStep.Diff; } catch (Exception ex) @@ -400,6 +429,193 @@ public partial class TransportImport : ComponentBase, IDisposable } } + /// + /// 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. + /// + /// Default choices follow the reference precedence: each site/connection + /// is seeded to its AutoMatch* value when the importer found a match, + /// otherwise to "Create new". The operator's explicit dropdown choice then + /// wins over the auto-match default. + /// + private async Task InitNameMappingStateAsync(ImportPreview preview) + { + _targetSites = Array.Empty(); + _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; + } + } + + /// + /// Returns the connections of the destination site the operator chose for + /// (used to populate a connection + /// row's dropdown). Reads the _targetConnections cache, which is + /// populated for every chosen target site by + /// (seeded auto-matches) and (operator + /// changes). Returns an empty list when the source site maps to "Create new" + /// (no existing target connections to bind to). + /// + private IReadOnlyList ConnectionsForChosenTarget(string sourceSiteIdentifier) + { + if (!_siteChoices.TryGetValue(sourceSiteIdentifier, out var targetSiteIdentifier) + || string.IsNullOrEmpty(targetSiteIdentifier)) + { + return Array.Empty(); + } + return _targetConnections.TryGetValue(targetSiteIdentifier, out var cached) + ? cached + : Array.Empty(); + } + + /// + /// 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. + /// + 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; + } + + /// + /// M8 E2: folds the operator's Map-step choices into a . + /// A concrete chosen target → with that + /// target identifier/name; the "Create new" sentinel → + /// 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 + /// when the preview carries no required mappings. + /// + private BundleNameMap BuildNameMap() + { + if (_preview is null + || (_preview.RequiredSiteMappings.Count == 0 && _preview.RequiredConnectionMappings.Count == 0)) + { + return BundleNameMap.Empty; + } + + var sites = new List(_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(_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); + } + + /// + /// M8 E2: parses a Modified item's FieldDiffJson and returns the + /// lineDiff 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. + /// + 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; + } + } + /// /// Builds the default resolution per preview item: /// @@ -527,11 +743,16 @@ public partial class TransportImport : ComponentBase, IDisposable try { 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( _session.SessionId, _resolutions.Values.ToList(), user, - CancellationToken.None); + CancellationToken.None, + nameMap: nameMap); _step = ImportWizardStep.Result; } catch (SemanticValidationException ex) @@ -564,6 +785,11 @@ public partial class TransportImport : ComponentBase, IDisposable _confirmEnvironmentText = string.Empty; _result = null; _validationErrors = null; + // M8 E2: clear the Map sub-section state too. + _targetSites = Array.Empty(); + _targetConnections.Clear(); + _siteChoices.Clear(); + _connectionChoices.Clear(); } /// diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/LineDiffView.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/LineDiffView.razor new file mode 100644 index 00000000..268bb47f --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/LineDiffView.razor @@ -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) +{ +
No line-level changes.
+} +else +{ +
+ @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), " "), + }; +
+ @gutter + @sign + @hunk.Text +
+ } +
+ + @if (_truncated) + { +
+ … diff truncated (+@_addedCount / -@_removedCount more) +
+ } +} + +@code { + /// + /// The parsed value of a Modified item's FieldDiffJson code field's + /// lineDiff key. When null the component renders nothing meaningful — + /// callers should only render it for code fields that actually carry a + /// lineDiff object. + /// + [Parameter] + public JsonElement? LineDiff { get; set; } + + private readonly List _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}"; + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs index d338f89e..024cb938 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs @@ -9,6 +9,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; 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.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; @@ -37,6 +39,10 @@ public class TransportImportPageTests : BunitContext { private readonly IBundleImporter _importer = Substitute.For(); private readonly IAuditService _auditService = Substitute.For(); + // 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(); public TransportImportPageTests() { @@ -44,6 +50,12 @@ public class TransportImportPageTests : BunitContext Services.AddSingleton(_importer); Services.AddSingleton(_auditService); + + _siteRepo.GetAllSitesAsync(Arg.Any()) + .Returns(Array.Empty()); + _siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any(), Arg.Any()) + .Returns(Array.Empty()); + Services.AddSingleton(_siteRepo); Services.AddSingleton>( Microsoft.Extensions.Options.Options.Create(new TransportOptions { @@ -248,7 +260,8 @@ public class TransportImportPageTests : BunitContext session.SessionId, Arg.Any>(), "alice", - Arg.Any()) + Arg.Any(), + Arg.Any()) .Returns(expectedResult); var cut = Render(); @@ -275,7 +288,8 @@ public class TransportImportPageTests : BunitContext rs.Any(r => r.EntityType == "Template" && r.Name == "Pump" && r.Action == ResolutionAction.Overwrite)), "alice", - Arg.Any()); + Arg.Any(), + Arg.Any()); Assert.Equal( TransportImportPage.ImportWizardStep.Result, @@ -329,6 +343,230 @@ public class TransportImportPageTests : BunitContext 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()) + .Returns(new List { new("Site B", "site-b") { Id = 7 } }); + _siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any()) + .Returns(new List { new("opc-main", "OpcUa", 7) { Id = 11 } }); + + var session = BuildEncryptedSession(sourceEnv: "prod-cluster"); + var preview = new ImportPreview( + session.SessionId, + new List(), + new List { new("site-a", "Site A", "site-b") }, + new List { new("site-a", "opc-a", "opc-main") }); + _importer.PreviewAsync(session.SessionId, Arg.Any()).Returns(preview); + + var cut = Render(); + 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 + { + new("Template", "Pump", null, 1, ConflictKind.New, null, null), + }); + _importer.PreviewAsync(session.SessionId, Arg.Any()).Returns(preview); + + var cut = Render(); + 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()) + .Returns(new List { new("Site B", "site-b") { Id = 7 } }); + _siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any()) + .Returns(new List { new("opc-main", "OpcUa", 7) { Id = 11 } }); + + var session = BuildEncryptedSession(sourceEnv: "prod-cluster"); + var preview = new ImportPreview( + session.SessionId, + new List(), + // site-a auto-matches site-b; site-c has no auto-match (→ Create new default). + new List + { + new("site-a", "Site A", "site-b"), + new("site-c", "Site C", null), + }, + // opc-a (under site-a) auto-matches opc-main. + new List { new("site-a", "opc-a", "opc-main") }); + _importer.PreviewAsync(session.SessionId, Arg.Any()).Returns(preview); + + var expectedResult = new ImportResult( + Guid.NewGuid(), 0, 0, 0, 0, Array.Empty(), Guid.NewGuid().ToString()); + _importer.ApplyAsync( + session.SessionId, + Arg.Any>(), + "alice", + Arg.Any(), + Arg.Any()) + .Returns(expectedResult); + + var cut = Render(); + 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>(), + "alice", + Arg.Any(), + Arg.Is(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 + { + new("Template", "Pump", 1, 2, ConflictKind.Modified, fieldDiffJson, null), + }); + _importer.PreviewAsync(session.SessionId, Arg.Any()).Returns(preview); + + var cut = Render(); + 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
 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
+        {
+            new("Template", "Pump", 1, 2, ConflictKind.Modified, fieldDiffJson, null),
+        });
+        _importer.PreviewAsync(session.SessionId, Arg.Any()).Returns(preview);
+
+        var cut = Render();
+        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
     // razor partial pattern). We poke at it via reflection rather than
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs
index 24cc2cf0..8ddb6f2a 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs
@@ -222,6 +222,8 @@ public sealed class QueryStringDrillInTests
             var importer = Substitute.For();
             Services.AddSingleton(importer);
             Services.AddSingleton(Substitute.For());
+            // M8 E2: TransportImport's Map step injects ISiteRepository.
+            Services.AddSingleton(Substitute.For());
             Services.AddSingleton>(
                 Microsoft.Extensions.Options.Options.Create(new TransportOptions
                 {