using System.Reflection; using System.Runtime.ExceptionServices; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing; using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw; using ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; using ZB.MOM.WW.OtOpcUa.Commons.Browsing; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw; /// /// Pins that a superseded open of the browse modal can never write its device's browse context under /// a different device's displayed modal. /// /// /// /// Blazor does not serialize OnParametersSetAsync against its own outstanding task, and the /// modal's _open bool cannot tell two opens apart. Its resolve awaits the merged probe /// config and the target group context, so without a post-await staleness re-check a /// close-reopen onto another device lets the first open's late continuation install its /// _driverType / _mergedConfig / _groupPrefix — and "Connect & browse" /// would then silently dial the wrong controller and commit under the wrong group. Same race, same /// fix as the two sibling modals (RawTagModal, RawManualTagEntryModal, 2026-08-05). /// /// /// AdminUI has no bUnit, so the component is driven directly as a class: its injected properties, /// its [Parameter]s (BL0005 forbids setting those from outside the component, and this /// project treats warnings as errors) and its private resolve state are reached by reflection. /// Unlike the siblings this modal calls StateHasChanged on the open path, which throws /// unless the component holds a render handle — hence the inert renderer below. /// /// // BL0006 warns off Microsoft.AspNetCore.Components.RenderTree, which is right for production code and // unavoidable here: subclassing Renderer is the only way to hand a component a render handle without // taking a bUnit dependency, and Renderer's abstract render method is typed in that namespace. The surface // used is that one parameter — no RenderTree type is inspected, stored or asserted on. #pragma warning disable BL0006 public sealed class RawBrowseModalStaleOpenTests : IDisposable { private const string ConfigA = """{"endpoint":"10.0.0.1:44818","slot":0}"""; private const string ConfigB = """{"endpoint":"10.0.0.2:44818","slot":1}"""; private readonly List _renderers = new(); [Fact] public async Task A_stale_open_never_feeds_the_browser_another_devices_config() { var deviceAResolve = new TaskCompletionSource(); var svc = new FakeRawTreeService { OnLoadMergedProbeConfigAsync = async deviceId => { if (deviceId == "dev-A") { await deviceAResolve.Task; return ("AbCip", ConfigA); } return ("TwinCAT", ConfigB); }, }; var modal = NewModal(svc, new FakeBrowserSessionService(), NullExportReader()); // Quick close-reopen onto another device while dev-A's resolve is still in flight. SetParameters(modal, visible: true, deviceId: "dev-A", tagGroupId: null); var staleOpen = OpenAsync(modal); SetParameters(modal, visible: false, deviceId: "dev-A", tagGroupId: null); await OpenAsync(modal); SetParameters(modal, visible: true, deviceId: "dev-B", tagGroupId: null); await OpenAsync(modal); deviceAResolve.SetResult(); await staleOpen; Field(modal, "_mergedConfig").ShouldBe(ConfigB); Field(modal, "_driverType").ShouldBe("TwinCAT"); } [Fact] public async Task A_stale_group_resolve_never_retargets_the_displayed_open() { var dbName = SeedGroup("TG-A", deviceId: "dev-A", name: "GroupA"); var groupResolve = new TaskCompletionSource(); var svc = new FakeRawTreeService { OnLoadMergedProbeConfig = deviceId => deviceId == "dev-A" ? ("AbCip", ConfigA) : null, }; var modal = NewModal( svc, new FakeBrowserSessionService(), new RawTagCsvExportReader(new GatedDbContextFactory(dbName, groupResolve.Task))); // The merged-config resolve completes inline here, so this open suspends inside the GROUP // resolve — the second await, which a fix that guards only the first one leaves unprotected. SetParameters(modal, visible: true, deviceId: "dev-A", tagGroupId: "TG-A"); var staleOpen = OpenAsync(modal); SetParameters(modal, visible: false, deviceId: "dev-A", tagGroupId: "TG-A"); await OpenAsync(modal); // Re-opened with no device and no target group: the displayed open commits at a device root it // has not resolved yet, so both writes the stale continuation makes are observable. SetParameters(modal, visible: true, deviceId: "", tagGroupId: null); await OpenAsync(modal); groupResolve.SetResult(); await staleOpen; Field(modal, "_groupPrefix").ShouldBeNull(); Field(modal, "_effectiveDeviceId").ShouldBe(""); } [Fact] public async Task A_stale_open_completing_late_never_clears_the_live_opens_loading_state() { var deviceAResolve = new TaskCompletionSource(); var deviceBResolve = new TaskCompletionSource(); var svc = new FakeRawTreeService { OnLoadMergedProbeConfigAsync = async deviceId => { if (deviceId == "dev-A") { await deviceAResolve.Task; return ("AbCip", ConfigA); } await deviceBResolve.Task; return ("TwinCAT", ConfigB); }, }; var modal = NewModal(svc, new FakeBrowserSessionService(), NullExportReader()); SetParameters(modal, visible: true, deviceId: "dev-A", tagGroupId: null); var staleOpen = OpenAsync(modal); SetParameters(modal, visible: false, deviceId: "dev-A", tagGroupId: null); await OpenAsync(modal); SetParameters(modal, visible: true, deviceId: "dev-B", tagGroupId: null); var liveOpen = OpenAsync(modal); // The stale run finishes while the live one is still resolving. Its tail state — the spinner // flag and the render it forces — belongs to an open that is no longer displayed: letting the // `finally` run would drop the live open's spinner and paint the not-yet-resolved // "Browsing unavailable" panel over a device that is still loading. deviceAResolve.SetResult(); await staleOpen; Field(modal, "_resolving").ShouldBeTrue(); deviceBResolve.SetResult(); await liveOpen; Field(modal, "_resolving").ShouldBeFalse(); } [Fact] public async Task A_stale_resolve_failure_never_paints_its_error_over_the_live_open() { var deviceAResolve = new TaskCompletionSource(); var svc = new FakeRawTreeService { OnLoadMergedProbeConfigAsync = async deviceId => { if (deviceId == "dev-A") { await deviceAResolve.Task; return ("AbCip", ConfigA); } return ("TwinCAT", ConfigB); }, }; var modal = NewModal(svc, new FakeBrowserSessionService(), NullExportReader()); SetParameters(modal, visible: true, deviceId: "dev-A", tagGroupId: null); var staleOpen = OpenAsync(modal); SetParameters(modal, visible: false, deviceId: "dev-A", tagGroupId: null); await OpenAsync(modal); SetParameters(modal, visible: true, deviceId: "dev-B", tagGroupId: null); await OpenAsync(modal); // A superseded open that FAILS is the same defect wearing the catch block: its diagnostic names // a device the operator is no longer looking at, and disables browsing for the one they are. deviceAResolve.SetException(new InvalidOperationException("dev-A unreachable")); await staleOpen; Field(modal, "_canBrowse").ShouldBeTrue(); Field(modal, "_disabledReason").ShouldBeNull(); } /// public void Dispose() { foreach (var renderer in _renderers) { renderer.Dispose(); } } /// Builds a modal with its three injected dependencies set and a render handle attached. private RawBrowseModal NewModal( IRawTreeService svc, IBrowserSessionService browser, RawTagCsvExportReader exportReader) { var modal = new RawBrowseModal(); Inject(modal, "Svc", svc); Inject(modal, "BrowserService", browser); Inject(modal, "ExportReader", exportReader); var renderer = new InertRenderer(); _renderers.Add(renderer); renderer.Attach(modal); return modal; } /// An export reader over a factory that fails loudly — for tests that must never reach it. private static RawTagCsvExportReader NullExportReader() => new(new GatedDbContextFactory(dbName: null, gate: Task.CompletedTask)); /// Seeds a lone tag-group row into a fresh InMemory database and returns its name. private static string SeedGroup(string tagGroupId, string deviceId, string name) { var dbName = $"rawbrowse-{Guid.NewGuid():N}"; using var db = RawTreeTestDb.CreateNamed(dbName); db.TagGroups.Add(new TagGroup { TagGroupId = tagGroupId, DeviceId = deviceId, Name = name }); db.SaveChanges(); return dbName; } /// /// Sets the modal's [Parameter]s. By reflection because BL0005 forbids assigning component /// parameters from outside the component, and this project treats warnings as errors. /// private static void SetParameters( RawBrowseModal modal, bool visible, string deviceId, string? tagGroupId) { Set("Visible", visible); Set("DeviceId", deviceId); Set("TagGroupId", tagGroupId); void Set(string name, object? value) { var property = typeof(RawBrowseModal).GetProperty(name); property.ShouldNotBeNull($"the [Parameter] {name} was renamed or removed."); property!.SetValue(modal, value); } } /// Sets one of the @inject-generated private properties. private static void Inject(RawBrowseModal modal, string name, object value) { var property = typeof(RawBrowseModal).GetProperty( name, BindingFlags.NonPublic | BindingFlags.Instance); property.ShouldNotBeNull($"the injected {name} property was renamed or removed."); property!.SetValue(modal, value); } /// Drives the open path — the lifecycle method the resolve hangs off. private static async Task OpenAsync(RawBrowseModal modal) { var method = typeof(RawBrowseModal).GetMethod( "OnParametersSetAsync", BindingFlags.NonPublic | BindingFlags.Instance); method.ShouldNotBeNull("the open path moved off OnParametersSetAsync — retarget this driver."); await (Task)method!.Invoke(modal, null)!; } /// Reads one of the modal's private resolve-state fields. private static T Field(RawBrowseModal modal, string name) { var field = typeof(RawBrowseModal).GetField(name, BindingFlags.NonPublic | BindingFlags.Instance); field.ShouldNotBeNull($"the modal's {name} field was renamed or removed."); return (T)field!.GetValue(modal)!; } /// /// An whose context creation can be held open, so a /// test can suspend a resolve inside ResolveGroupContextAsync and interleave a second open. /// A null database name means "must never be called". /// private sealed class GatedDbContextFactory(string? dbName, Task gate) : IDbContextFactory { /// public OtOpcUaConfigDbContext CreateDbContext() { dbName.ShouldNotBeNull("the group-context resolve was reached by a test that forbids it."); return RawTreeTestDb.CreateNamed(dbName!); } /// public async Task CreateDbContextAsync(CancellationToken ct = default) { await gate; return CreateDbContext(); } } /// /// Browse gate stand-in: every driver+config can browse, so a test asserting that a stale run did /// not disable browsing has a true baseline to be falsified against. Nothing else is reachable — /// these tests never open a session. /// private sealed class FakeBrowserSessionService : IBrowserSessionService { /// public bool CanBrowse(string driverType, string configJson) => true; /// public Task OpenAsync(string driverType, string configJson, CancellationToken ct) => throw new NotImplementedException(); /// public Task> RootAsync(Guid token, CancellationToken ct) => throw new NotImplementedException(); /// public Task> ExpandAsync(Guid token, string nodeId, CancellationToken ct) => throw new NotImplementedException(); /// public Task> AttributesAsync(Guid token, string nodeId, CancellationToken ct) => throw new NotImplementedException(); /// public Task RequestRebirthAsync(Guid token, string scope, CancellationToken ct) => throw new NotImplementedException(); /// public bool CanRequestRebirth(Guid token) => false; /// public Task CloseAsync(Guid token) => throw new NotImplementedException(); } /// /// The smallest renderer that gives a component a working render handle: StateHasChanged /// throws without one, and this modal calls it on the open path. Renders are built and then /// discarded — the tests read the modal's state, not its markup. /// private sealed class InertRenderer() : Renderer(new ServiceCollection().BuildServiceProvider(), NullLoggerFactory.Instance) { /// public override Dispatcher Dispatcher { get; } = new InlineDispatcher(); /// Assigns the component a render handle, as a root component would receive. public void Attach(IComponent component) => AssignRootComponentId(component); /// protected override void HandleException(Exception exception) => ExceptionDispatchInfo.Capture(exception).Throw(); /// protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask; } /// /// Runs everything on the calling thread. The real dispatcher would refuse a /// StateHasChanged raised from a test thread (or from a resolve continuation), which is /// exactly what these tests do. /// private sealed class InlineDispatcher : Dispatcher { /// public override bool CheckAccess() => true; /// public override Task InvokeAsync(Action workItem) { workItem(); return Task.CompletedTask; } /// public override Task InvokeAsync(Func workItem) => workItem(); /// public override Task InvokeAsync(Func workItem) => Task.FromResult(workItem()); /// public override Task InvokeAsync(Func> workItem) => workItem(); } }