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.Components.Shared.Drivers; using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw; using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Uns; using ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw; /// /// Issue #604: the _openGeneration stale-open guard (already on RawBrowseModal / /// RawManualTagEntryModal) on the five modals that lacked it. Each test opens the modal onto /// target A with the async resolve held open, closes, reopens onto target B, then lets A's resolve /// finish: A's continuation must not write A's state under B's displayed modal. /// /// /// AdminUI has no bUnit, so components are driven as plain classes (reflection for the injected /// properties, [Parameter]s and private state — same harness as /// ). Every test releases the stale resolve LAST and asserts /// the live open's value survived; reverting the guard makes the stale value win. /// #pragma warning disable BL0006 public sealed class ModalStaleOpenGuardTests : IDisposable { private readonly List _renderers = new(); [Fact] public async Task CsvImport_stale_group_resolve_never_retargets_the_live_open() { var dbName = SeedGroup("TG-A", "dev-A", "GroupA"); var gate = new TaskCompletionSource(); var modal = Make( ("ExportReader", new RawTagCsvExportReader(new GatedFactory(dbName, gate.Task)))); SetParams(modal, ("Visible", true), ("DeviceId", ""), ("TagGroupId", "TG-A")); var stale = Open(modal); SetParams(modal, ("Visible", false)); await Open(modal); SetParams(modal, ("Visible", true), ("DeviceId", ""), ("TagGroupId", null)); await Open(modal); gate.SetResult(); await stale; Field(modal, "_groupPrefix").ShouldBeNull(); Field(modal, "_effectiveDeviceId").ShouldBe(""); } [Fact] public async Task CsvImport_commit_that_lands_after_the_modal_closed_still_raises_OnSaved() { // A write that has landed must refresh the parent's tree even when the operator closed the modal // mid-commit; before the fix the stale guard returned ahead of OnSaved. var saved = await CommitWhileClosed(new RawTagImportResult(1, [])); saved.ShouldBe(1); // Control: a failed (all-or-nothing) import wrote nothing, so there is nothing to refresh. var savedOnFailure = await CommitWhileClosed(new RawTagImportResult(0, ["row 1: nope"])); savedOnFailure.ShouldBe(0); } private async Task CommitWhileClosed(RawTagImportResult outcome) { var gate = new TaskCompletionSource(); var svc = Stub(async (method, _) => { if (method.Name != nameof(IRawTreeService.ImportTagsAsync)) { return null; } await gate.Task; return outcome; }); var modal = Make(("Svc", svc)); var saved = 0; SetParams(modal, ("Visible", true), ("DeviceId", "dev-A"), ("TagGroupId", null), ("DriverType", "Modbus"), ("OnSaved", EventCallback.Factory.Create(this, () => { saved++; }))); await Open(modal); modal.GetType().GetField("_result", BindingFlags.NonPublic | BindingFlags.Instance)!.SetValue( modal, RawTagCsvMapper.Parse("Modbus", "Name,DataType,TagConfigJson\nok,Float,\"{\"\"region\"\":\"\"HoldingRegisters\"\",\"\"address\"\":1}\"")); var commit = (Task)modal.GetType().GetMethod("Commit", BindingFlags.NonPublic | BindingFlags.Instance)! .Invoke(modal, null)!; SetParams(modal, ("Visible", false)); await Open(modal); gate.SetResult(); await commit; return saved; } [Fact] public async Task CsvExport_stale_group_resolve_never_retargets_the_live_open() { var dbName = SeedGroup("TG-A", "dev-A", "GroupA"); var gate = new TaskCompletionSource(); var modal = Make( ("ExportReader", new RawTagCsvExportReader(new GatedFactory(dbName, gate.Task)))); SetParams(modal, ("Visible", true), ("DeviceId", ""), ("TagGroupId", "TG-A")); var stale = Open(modal); SetParams(modal, ("Visible", false)); await Open(modal); SetParams(modal, ("Visible", true), ("DeviceId", ""), ("TagGroupId", null)); await Open(modal); gate.SetResult(); await stale; Field(modal, "_groupFilter").ShouldBeNull(); Field(modal, "_rowCount").ShouldBe(0); Field(modal, "_dataUri").ShouldBe(""); } [Fact] public async Task DeviceModal_stale_load_never_installs_the_old_devices_fields() { var gate = new TaskCompletionSource(); var svc = Stub(async (method, args) => { if (method.Name != nameof(IRawTreeService.LoadDeviceForEditAsync)) { return null; } if ((string)args[0]! == "dev-A") { await gate.Task; return new RawDeviceEditDto("dev-A", "drv", "NameA", "{}", true, "Modbus", []); } return new RawDeviceEditDto("dev-B", "", "NameB", "{}", true, "Modbus", []); }); var modal = Make(("Svc", svc)); SetParams(modal, ("Visible", true), ("DeviceId", "dev-A")); var stale = Open(modal); SetParams(modal, ("Visible", false)); await Open(modal); SetParams(modal, ("Visible", true), ("DeviceId", "dev-B")); await Open(modal); gate.SetResult(); await stale; Field(modal, "_name").ShouldBe("NameB"); } [Fact] public async Task DriverConfigModal_stale_load_never_installs_the_old_drivers_fields() { var gate = new TaskCompletionSource(); var svc = Stub(async (method, args) => { if (method.Name != nameof(IRawTreeService.LoadDriverForEditAsync)) { return null; } if ((string)args[0]! == "drv-A") { await gate.Task; return new RawDriverEditDto("drv-A", "NameA", "Modbus", """{"a":1}""", null, []); } return new RawDriverEditDto("drv-B", "NameB", "Modbus", """{"b":2}""", null, []); }); var modal = Make(("Svc", svc)); SetParams(modal, ("Visible", true), ("DriverInstanceId", "drv-A")); var stale = Open(modal); SetParams(modal, ("Visible", false)); await Open(modal); SetParams(modal, ("Visible", true), ("DriverInstanceId", "drv-B")); await Open(modal); gate.SetResult(); await stale; Field(modal, "_name").ShouldBe("NameB"); Field(modal, "_driverConfigJson").ShouldBe("""{"b":2}"""); } [Fact] public async Task AddReferenceModal_stale_root_load_never_replaces_the_live_opens_tree() { var gate = new TaskCompletionSource(); var svc = Stub(async (method, args) => { if (method.Name != nameof(IUnsTreeService.LoadReferencePickerRootAsync)) { return null; } if ((string)args[0]! == "eq-A") { await gate.Task; return Root("root-A"); } return Root("root-B"); }); var modal = Make(("Svc", svc)); SetParams(modal, ("Visible", true), ("EquipmentId", "eq-A")); var stale = Open(modal); SetParams(modal, ("Visible", false)); await Open(modal); SetParams(modal, ("Visible", true), ("EquipmentId", "eq-B")); await Open(modal); gate.SetResult(); await stale; Field>(modal, "_roots").Single().Key.ShouldBe("root-B"); } // ---- harness ---- private static RawNode Root(string key) => new() { Kind = RawNodeKind.Cluster, Key = key, DisplayName = key }; private static string SeedGroup(string tagGroupId, string deviceId, string name) { var dbName = $"modalstale-{Guid.NewGuid():N}"; using var db = RawTreeTestDb.CreateNamed(dbName); db.TagGroups.Add(new TagGroup { TagGroupId = tagGroupId, DeviceId = deviceId, Name = name }); db.SaveChanges(); return dbName; } private T Make(params (string Name, object Value)[] injected) where T : class, IComponent, new() { var component = new T(); foreach (var (name, value) in injected) { var property = typeof(T).GetProperty(name, BindingFlags.NonPublic | BindingFlags.Instance); property.ShouldNotBeNull($"the injected {name} property was renamed or removed."); property!.SetValue(component, value); } var renderer = new InertRenderer(); _renderers.Add(renderer); renderer.Attach(component); return component; } private static void SetParams(IComponent modal, params (string Name, object? Value)[] values) { foreach (var (name, value) in values) { var property = modal.GetType().GetProperty(name); property.ShouldNotBeNull($"the [Parameter] {name} was renamed or removed."); property!.SetValue(modal, value); } } private static async Task Open(IComponent modal) { var method = modal.GetType().GetMethod( "OnParametersSetAsync", BindingFlags.NonPublic | BindingFlags.Instance); method.ShouldNotBeNull("the open path moved off OnParametersSetAsync."); await (Task)method!.Invoke(modal, null)!; } private static T Field(object modal, string name) { var field = modal.GetType().GetField(name, BindingFlags.NonPublic | BindingFlags.Instance); field.ShouldNotBeNull($"the modal's {name} field was renamed or removed."); return (T)field!.GetValue(modal)!; } /// /// A throwaway implementation of a service interface: every call goes to , /// whose result is wrapped as the method's Task<T>. A null result is a null payload. /// private static TService Stub(Func> handler) where TService : class { var proxy = DispatchProxy.Create(); ((StubProxy)(object)proxy).Handler = handler; return proxy; } /// public void Dispose() { foreach (var renderer in _renderers) { renderer.Dispose(); } } /// DispatchProxy body for . public class StubProxy : DispatchProxy { /// Receives every call; set right after construction. public Func> Handler { get; set; } = default!; /// protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) { var inner = targetMethod!.ReturnType.GetGenericArguments()[0]; var task = Handler(targetMethod, args ?? []); var tcs = typeof(TaskCompletionSource<>).MakeGenericType(inner); var source = Activator.CreateInstance(tcs)!; task.ContinueWith(t => { if (t.IsFaulted) { tcs.GetMethod("SetException", [typeof(Exception)])!.Invoke(source, [t.Exception!.GetBaseException()]); } else { tcs.GetMethod("SetResult")!.Invoke(source, [t.Result]); } }); return tcs.GetProperty("Task")!.GetValue(source); } } private sealed class GatedFactory(string dbName, Task gate) : IDbContextFactory { /// public OtOpcUaConfigDbContext CreateDbContext() => RawTreeTestDb.CreateNamed(dbName); /// public async Task CreateDbContextAsync(CancellationToken ct = default) { await gate; return CreateDbContext(); } } 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; } 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(); } }