feat(transport-ui): import Map step + per-line diff view (M8 E2)
This commit is contained in:
+240
-2
@@ -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<IBundleImporter>();
|
||||
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()
|
||||
{
|
||||
@@ -44,6 +50,12 @@ public class TransportImportPageTests : BunitContext
|
||||
|
||||
Services.AddSingleton(_importer);
|
||||
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>>(
|
||||
Microsoft.Extensions.Options.Options.Create(new TransportOptions
|
||||
{
|
||||
@@ -248,7 +260,8 @@ public class TransportImportPageTests : BunitContext
|
||||
session.SessionId,
|
||||
Arg.Any<IReadOnlyList<ImportResolution>>(),
|
||||
"alice",
|
||||
Arg.Any<CancellationToken>())
|
||||
Arg.Any<CancellationToken>(),
|
||||
Arg.Any<BundleNameMap>())
|
||||
.Returns(expectedResult);
|
||||
|
||||
var cut = Render<TransportImportPage>();
|
||||
@@ -275,7 +288,8 @@ public class TransportImportPageTests : BunitContext
|
||||
rs.Any(r => r.EntityType == "Template" && r.Name == "Pump"
|
||||
&& r.Action == ResolutionAction.Overwrite)),
|
||||
"alice",
|
||||
Arg.Any<CancellationToken>());
|
||||
Arg.Any<CancellationToken>(),
|
||||
Arg.Any<BundleNameMap>());
|
||||
|
||||
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<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
|
||||
// razor partial pattern). We poke at it via reflection rather than
|
||||
|
||||
@@ -222,6 +222,8 @@ public sealed class QueryStringDrillInTests
|
||||
var importer = Substitute.For<IBundleImporter>();
|
||||
Services.AddSingleton(importer);
|
||||
Services.AddSingleton(Substitute.For<IAuditService>());
|
||||
// M8 E2: TransportImport's Map step injects ISiteRepository.
|
||||
Services.AddSingleton(Substitute.For<ISiteRepository>());
|
||||
Services.AddSingleton<IOptions<TransportOptions>>(
|
||||
Microsoft.Extensions.Options.Options.Create(new TransportOptions
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user