Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/ModalStaleOpenGuardTests.cs
T
Joseph Doherty 1475991fd6 fix(adminui,client): error boundary inside the interactive scope, stale-commit OnSaved, AbCip browse delay emit, DisposeBridge cancel (review R3)
M1: PageErrorBoundary wraps the body of every InteractiveServer page; the inert layout boundary is removed.
m1: DisposeBridge cancels its CTS when the 5 s wait times out.
m2: a CSV import that commits after the modal closed still raises OnSaved.
m3: document SecurityMode=None no-fallback in Client.CLI.md / Client.UI.md.
m4: browsePageDelayMs written only when loaded or changed; negative flagged.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:31:19 -04:00

366 lines
14 KiB
C#

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;
/// <summary>
/// Issue #604: the <c>_openGeneration</c> stale-open guard (already on <c>RawBrowseModal</c> /
/// <c>RawManualTagEntryModal</c>) 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.
/// </summary>
/// <remarks>
/// AdminUI has no bUnit, so components are driven as plain classes (reflection for the injected
/// properties, <c>[Parameter]</c>s and private state — same harness as
/// <see cref="RawBrowseModalStaleOpenTests"/>). Every test releases the stale resolve LAST and asserts
/// the live open's value survived; reverting the guard makes the stale value win.
/// </remarks>
#pragma warning disable BL0006
public sealed class ModalStaleOpenGuardTests : IDisposable
{
private readonly List<InertRenderer> _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<RawCsvImportModal>(
("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<string?>(modal, "_groupPrefix").ShouldBeNull();
Field<string>(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<int> CommitWhileClosed(RawTagImportResult outcome)
{
var gate = new TaskCompletionSource();
var svc = Stub<IRawTreeService>(async (method, _) =>
{
if (method.Name != nameof(IRawTreeService.ImportTagsAsync)) { return null; }
await gate.Task;
return outcome;
});
var modal = Make<RawCsvImportModal>(("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<RawCsvExportModal>(
("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<string?>(modal, "_groupFilter").ShouldBeNull();
Field<int>(modal, "_rowCount").ShouldBe(0);
Field<string>(modal, "_dataUri").ShouldBe("");
}
[Fact]
public async Task DeviceModal_stale_load_never_installs_the_old_devices_fields()
{
var gate = new TaskCompletionSource();
var svc = Stub<IRawTreeService>(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<DeviceModal>(("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<string>(modal, "_name").ShouldBe("NameB");
}
[Fact]
public async Task DriverConfigModal_stale_load_never_installs_the_old_drivers_fields()
{
var gate = new TaskCompletionSource();
var svc = Stub<IRawTreeService>(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<DriverConfigModal>(("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<string>(modal, "_name").ShouldBe("NameB");
Field<string>(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<IUnsTreeService>(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<AddReferenceModal>(("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<IReadOnlyList<RawNode>>(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<T>(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<T>(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)!;
}
/// <summary>
/// A throwaway implementation of a service interface: every call goes to <paramref name="handler"/>,
/// whose result is wrapped as the method's <c>Task&lt;T&gt;</c>. A null result is a null payload.
/// </summary>
private static TService Stub<TService>(Func<MethodInfo, object?[], Task<object?>> handler)
where TService : class
{
var proxy = DispatchProxy.Create<TService, StubProxy>();
((StubProxy)(object)proxy).Handler = handler;
return proxy;
}
/// <inheritdoc/>
public void Dispose()
{
foreach (var renderer in _renderers) { renderer.Dispose(); }
}
/// <summary>DispatchProxy body for <see cref="Stub{TService}"/>.</summary>
public class StubProxy : DispatchProxy
{
/// <summary>Receives every call; set right after construction.</summary>
public Func<MethodInfo, object?[], Task<object?>> Handler { get; set; } = default!;
/// <inheritdoc/>
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<OtOpcUaConfigDbContext>
{
/// <inheritdoc/>
public OtOpcUaConfigDbContext CreateDbContext() => RawTreeTestDb.CreateNamed(dbName);
/// <inheritdoc/>
public async Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken ct = default)
{
await gate;
return CreateDbContext();
}
}
private sealed class InertRenderer()
: Renderer(new ServiceCollection().BuildServiceProvider(), NullLoggerFactory.Instance)
{
/// <inheritdoc/>
public override Dispatcher Dispatcher { get; } = new InlineDispatcher();
/// <summary>Assigns the component a render handle, as a root component would receive.</summary>
public void Attach(IComponent component) => AssignRootComponentId(component);
/// <inheritdoc/>
protected override void HandleException(Exception exception)
=> ExceptionDispatchInfo.Capture(exception).Throw();
/// <inheritdoc/>
protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask;
}
private sealed class InlineDispatcher : Dispatcher
{
/// <inheritdoc/>
public override bool CheckAccess() => true;
/// <inheritdoc/>
public override Task InvokeAsync(Action workItem)
{
workItem();
return Task.CompletedTask;
}
/// <inheritdoc/>
public override Task InvokeAsync(Func<Task> workItem) => workItem();
/// <inheritdoc/>
public override Task<TResult> InvokeAsync<TResult>(Func<TResult> workItem)
=> Task.FromResult(workItem());
/// <inheritdoc/>
public override Task<TResult> InvokeAsync<TResult>(Func<Task<TResult>> workItem) => workItem();
}
}