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();
}
}