();
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);
}
// ─────────────────────────────────────────────────────────────────────
// Test 12 (M8 E2 Fix 1): changing a source site's dropdown to a target
// that lacks the previously auto-matched connection name resets that
// connection choice to CreateNew (""), and BuildNameMap therefore emits
// a CreateNew entry — not a MapToExisting for an absent connection.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task OnSiteChoiceChanged_resets_stale_connection_choices_when_new_target_lacks_connection()
{
// Target environment: site-b has connection "opc-main"; site-c has NO connections.
var siteB = new Site("Site B", "site-b") { Id = 7 };
var siteC = new Site("Site C", "site-c") { Id = 8 };
_siteRepo.GetAllSitesAsync(Arg.Any())
.Returns(new List { siteB, siteC });
_siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any())
.Returns(new List { new("opc-main", "OpcUa", 7) { Id = 11 } });
_siteRepo.GetDataConnectionsBySiteIdAsync(8, Arg.Any())
.Returns(Array.Empty());
// Bundle: source site "site-a" auto-matches site-b; connection "opc-a" auto-matches "opc-main".
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));
// Drive LoadPreviewAndAdvanceAsync to seed auto-match state (site-a → site-b, opc-a → opc-main).
await cut.InvokeAsync(async () =>
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
// Verify the auto-match seed: connection choice is "opc-main".
var connChoicesBefore = GetField>(
cut.Instance, "_connectionChoices");
Assert.Equal("opc-main", connChoicesBefore[("site-a", "opc-a")]);
// Operator changes site-a's target from site-b → site-c (which has no connections).
await cut.InvokeAsync(async () =>
{
var method = cut.Instance.GetType().GetMethod(
"OnSiteChoiceChangedAsync",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
await (Task)method.Invoke(cut.Instance, new object[] { "site-a", "site-c" })!;
});
// Connection choice for (site-a, opc-a) must now be CreateNew ("").
var connChoicesAfter = GetField>(
cut.Instance, "_connectionChoices");
Assert.Equal(string.Empty, connChoicesAfter[("site-a", "opc-a")]);
// BuildNameMap must NOT emit MapToExisting for "opc-a" — it should emit CreateNew.
var nameMap = TransportImportPageTests.InvokeBuildNameMap(cut.Instance);
var connMapping = nameMap.Connections.Single(c => c.SourceConnectionName == "opc-a");
Assert.Equal(MappingAction.CreateNew, connMapping.Action);
Assert.Null(connMapping.TargetConnectionName);
}
// ─────────────────────────────────────────────────────────────────────
// Test 13 (M8 E2 Fix 2): BackToUpload clears session/preview/Map state
// so a subsequent re-upload flow cannot inherit stale data.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task BackToUpload_clears_session_and_preview_state()
{
// Seed the wizard at the Diff step with non-trivial session + preview + Map state.
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
var preview = new ImportPreview(
session.SessionId,
new List
{
new("Template", "Pump", null, 1, ConflictKind.New, null, null),
},
new List { new("site-a", "Site A", null) },
new List { new("site-a", "conn-x", null) });
var cut = Render();
await cut.InvokeAsync(() =>
{
SetField(cut.Instance, "_session", session);
SetField(cut.Instance, "_preview", preview);
SetField(cut.Instance, "_resolutions",
new Dictionary<(string, string), ImportResolution>
{
[("Template", "Pump")] = new("Template", "Pump", ResolutionAction.Add, null),
});
SetField(cut.Instance, "_siteChoices",
new Dictionary { ["site-a"] = "site-b" });
SetField(cut.Instance, "_connectionChoices",
new Dictionary<(string, string), string> { [("site-a", "conn-x")] = "conn-y" });
SetField(cut.Instance, "_step", TransportImportPage.ImportWizardStep.Diff);
});
// Invoke BackToUpload.
await cut.InvokeAsync(() =>
{
var method = cut.Instance.GetType().GetMethod(
"BackToUpload",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
method.Invoke(cut.Instance, Array.Empty