fix(transport-ui): reset connection choices on site change + clear state on back (M8 E2 review)

Fix 1: OnSiteChoiceChangedAsync now resets _connectionChoices for every
RequiredConnectionMapping under the changed source site after loading the new
target's connections. Choices are re-seeded to the same-named connection on
the new target if present, or CreateNewValue otherwise — preventing BuildNameMap
from emitting MapToExisting for a connection absent from the newly-chosen target.

Fix 2: BackToUpload now calls ResetSessionState() before resetting _step so
_session, _preview, _resolutions, _siteChoices, _connectionChoices,
_targetSites, and _targetConnections are all cleared when the operator backs
out to re-upload, making it safe to start a new import flow from a clean slate.

Tests 12 + 13 added to TransportImportPageTests.
This commit is contained in:
Joseph Doherty
2026-06-18 07:39:19 -04:00
parent d45a7a5760
commit f881521cc9
2 changed files with 168 additions and 4 deletions
@@ -567,6 +567,127 @@ public class TransportImportPageTests : BunitContext
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<CancellationToken>())
.Returns(new List<Site> { siteB, siteC });
_siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any<CancellationToken>())
.Returns(new List<DataConnection> { new("opc-main", "OpcUa", 7) { Id = 11 } });
_siteRepo.GetDataConnectionsBySiteIdAsync(8, Arg.Any<CancellationToken>())
.Returns(Array.Empty<DataConnection>());
// 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<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));
// 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<Dictionary<(string, string), string>>(
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<Dictionary<(string, string), string>>(
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<ImportPreviewItem>
{
new("Template", "Pump", null, 1, ConflictKind.New, null, null),
},
new List<RequiredSiteMapping> { new("site-a", "Site A", null) },
new List<RequiredConnectionMapping> { new("site-a", "conn-x", null) });
var cut = Render<TransportImportPage>();
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<string, string> { ["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<object?>());
});
// Step must be back to Upload.
Assert.Equal(
TransportImportPage.ImportWizardStep.Upload,
GetField<TransportImportPage.ImportWizardStep>(cut.Instance, "_step"));
// Session, preview, resolutions, and Map state must all be cleared.
Assert.Null(GetField<object?>(cut.Instance, "_session"));
Assert.Null(GetField<object?>(cut.Instance, "_preview"));
Assert.Null(GetField<object?>(cut.Instance, "_resolutions"));
var siteChoices = GetField<Dictionary<string, string>>(cut.Instance, "_siteChoices");
Assert.Empty(siteChoices);
var connChoices = GetField<Dictionary<(string, string), string>>(cut.Instance, "_connectionChoices");
Assert.Empty(connChoices);
}
// ─────────────────────────────────────────────────────────────────────
// Reflection helpers — the wizard's per-instance state is private (the
// razor partial pattern). We poke at it via reflection rather than
@@ -607,6 +728,20 @@ public class TransportImportPageTests : BunitContext
await task;
}
/// <summary>
/// Invokes the private <c>BuildNameMap()</c> method on the page instance and
/// returns the resulting <see cref="BundleNameMap"/>. Used by Test 12 to assert
/// the map shape after a site-choice change without going through ApplyAsync.
/// </summary>
private static BundleNameMap InvokeBuildNameMap(TransportImportPage instance)
{
var method = instance.GetType().GetMethod(
"BuildNameMap",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)
?? throw new InvalidOperationException("Method 'BuildNameMap' not found.");
return (BundleNameMap)method.Invoke(instance, Array.Empty<object?>())!;
}
/// <summary>
/// Seeds the wizard at Step 2 (Passphrase) with a staged bundle file — the
/// shape after an encrypted-bundle upload completed Step 1's peek and