From d3bca7afd452a5035cb990314deee7962aca20f0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 06:55:23 -0400 Subject: [PATCH] fix(adminui): stale-open guards, validation summaries, key-delete confirm, error boundary (#604) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- .../Components/Layout/MainLayout.razor | 32 +- .../Components/Pages/ApiKeys.razor | 25 +- .../Pages/Clusters/ClusterEdit.razor | 1 + .../Pages/Clusters/NewCluster.razor | 1 + .../Components/Pages/ScriptEdit.razor | 1 + .../Shared/Drivers/DeviceModal.razor | 14 +- .../Shared/Drivers/DriverConfigModal.razor | 13 +- .../Shared/Raw/RawCsvExportModal.razor | 17 +- .../Shared/Raw/RawCsvImportModal.razor | 22 +- .../Shared/Uns/AddReferenceModal.razor | 10 + .../Raw/AdminUiFormSweepGuardTests.cs | 60 ++++ .../Raw/ModalStaleOpenGuardTests.cs | 321 ++++++++++++++++++ 12 files changed, 504 insertions(+), 13 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/AdminUiFormSweepGuardTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/ModalStaleOpenGuardTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor index 4b89dd6b..f6e31dfe 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor @@ -1,4 +1,5 @@ @inherits LayoutComponentBase +@implements IDisposable @using System.Security.Claims @* Thin delegation to the shared ZB.MOM.WW.Theme side-rail chassis. ThemeShell owns @@ -69,5 +70,34 @@ - @Body + + @* A throwing page would otherwise kill the whole circuit (blank "reload" bar). The boundary + confines it to the page body and offers Recover; navigating away also recovers it. It does NOT + catch exceptions from async-void handlers or timer callbacks. *@ + + @Body + + + + + + +@code { + private ErrorBoundary? _boundary; + + [Inject] private NavigationManager Nav { get; set; } = default!; + + protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged; + + // A fresh navigation clears a tripped boundary so the next page isn't stuck behind the old error. + private void OnLocationChanged(object? sender, Microsoft.AspNetCore.Components.Routing.LocationChangedEventArgs e) + => _boundary?.Recover(); + + /// + public void Dispose() => Nav.LocationChanged -= OnLocationChanged; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor index 576a2144..f8c69867 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor @@ -3,6 +3,7 @@ @rendermode RenderMode.InteractiveServer @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.Core.Authorization +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw @inject IApiKeyAdminService Keys
@@ -104,7 +105,7 @@ @(k.Enabled ? "Revoke" : "Re-enable") + @onclick="() => RequestDelete(k)" disabled="@_busy">Delete
@@ -114,6 +115,9 @@ } + + @code { private IReadOnlyList? _keys; private string _newName = ""; @@ -123,6 +127,12 @@ private string? _error; private bool _busy; + // Delete is irreversible (the key stops authenticating at once), so it goes through a confirm dialog + // that names the key rather than firing from the row button. + private bool _confirmDeleteVisible; + private string? _confirmDeleteId; + private string _confirmDeleteMessage = ""; + protected override async Task OnInitializedAsync() => await ReloadAsync(); private async Task ReloadAsync() @@ -183,6 +193,19 @@ finally { _busy = false; } } + private void RequestDelete(ApiKey key) + { + _confirmDeleteId = key.ApiKeyId; + _confirmDeleteMessage = + $"Permanently delete API key '{key.Name}' ({key.ApiKeyId})? Clients using it will stop authenticating immediately. This cannot be undone."; + _confirmDeleteVisible = true; + } + + private async Task ConfirmDeleteAsync() + { + if (_confirmDeleteId is { } id) { await DeleteAsync(id); } + } + private async Task DeleteAsync(string apiKeyId) { _busy = true; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEdit.razor index 6aa69f5f..87640b98 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEdit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEdit.razor @@ -34,6 +34,7 @@ else { +
Identity
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NewCluster.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NewCluster.razor index 66ced051..2cccf6a5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NewCluster.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NewCluster.razor @@ -17,6 +17,7 @@ +
Identity
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor index fcb433c0..df0a2e5b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor @@ -31,6 +31,7 @@ else { +
Identity
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor index 9ca4b51e..aee2778e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor @@ -123,20 +123,27 @@ private string? _saveError; private bool _openHandled; + // Bumped on every open and close; LoadAsync re-checks it after each await so a superseded open can't + // install one target's fields under another target's displayed modal (RawBrowseModal idiom). + private int _openGeneration; + + private bool IsStale(int generation) => generation != _openGeneration || !Visible; + protected override async Task OnParametersSetAsync() { if (Visible && !_openHandled) { _openHandled = true; - await LoadAsync(); + await LoadAsync(++_openGeneration); } else if (!Visible) { _openHandled = false; + _openGeneration++; } } - private async Task LoadAsync() + private async Task LoadAsync(int generation) { _busy = false; _loadError = null; _saveError = null; _isNew = string.IsNullOrEmpty(DeviceId); @@ -153,6 +160,7 @@ else { var dto = await Svc.LoadDeviceForEditAsync(DeviceId!); + if (IsStale(generation)) { return; } if (dto is null) { _loadError = $"Device '{DeviceId}' was not found."; return; } _resolvedDriverInstanceId = dto.DriverInstanceId; _driverType = dto.DriverType; @@ -166,6 +174,7 @@ if (!string.IsNullOrEmpty(_resolvedDriverInstanceId)) { var drv = await Svc.LoadDriverForEditAsync(_resolvedDriverInstanceId); + if (IsStale(generation)) { return; } _parentDriverConfig = drv?.DriverConfig ?? "{}"; } } @@ -217,6 +226,7 @@ { Visible = value; _openHandled = value; + _openGeneration++; await VisibleChanged.InvokeAsync(value); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor index 7a93ef0a..d15c8a8c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor @@ -129,20 +129,27 @@ private string? _saveError; private bool _openHandled; + // Bumped on every open and close; LoadAsync re-checks it after each await so a superseded open can't + // install one target's fields under another target's displayed modal (RawBrowseModal idiom). + private int _openGeneration; + + private bool IsStale(int generation) => generation != _openGeneration || !Visible; + protected override async Task OnParametersSetAsync() { if (Visible && !_openHandled) { _openHandled = true; - await LoadAsync(); + await LoadAsync(++_openGeneration); } else if (!Visible) { _openHandled = false; + _openGeneration++; } } - private async Task LoadAsync() + private async Task LoadAsync(int generation) { _busy = false; _loadError = null; _saveError = null; _isNew = string.IsNullOrEmpty(DriverInstanceId); @@ -158,6 +165,7 @@ else { var dto = await Svc.LoadDriverForEditAsync(DriverInstanceId!); + if (IsStale(generation)) { return; } if (dto is null) { _loadError = $"Driver '{DriverInstanceId}' was not found."; return; } _driverType = dto.DriverType; _name = dto.Name; @@ -206,6 +214,7 @@ { Visible = value; _openHandled = value; + _openGeneration++; await VisibleChanged.InvokeAsync(value); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvExportModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvExportModal.razor index a7af44d6..99ecdad4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvExportModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvExportModal.razor @@ -85,20 +85,26 @@ private int _rowCount; private string? _groupFilter; + // Bumped on every open and close so a superseded build can't install its CSV under another device's modal. + private int _openGeneration; + protected override async Task OnParametersSetAsync() { if (Visible && !_built) { _built = true; - await BuildAsync(); + await BuildAsync(++_openGeneration); } else if (!Visible) { _built = false; + _openGeneration++; } } - private async Task BuildAsync() + private bool IsStale(int generation) => generation != _openGeneration || !Visible; + + private async Task BuildAsync(int generation) { _loading = true; _error = null; @@ -110,6 +116,7 @@ if (!string.IsNullOrEmpty(TagGroupId)) { var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!); + if (IsStale(generation)) { return; } if (ctx is { } c) { _groupFilter = c.GroupPath; @@ -127,6 +134,7 @@ } var tags = await ExportReader.ReadDeviceTagsAsync(deviceId); + if (IsStale(generation)) { return; } // When exporting a single group, keep only that group's subtree (its path + descendants). if (!string.IsNullOrEmpty(_groupFilter)) @@ -147,11 +155,11 @@ } catch (Exception ex) { - _error = ex.Message; + if (!IsStale(generation)) { _error = ex.Message; } } finally { - _loading = false; + if (!IsStale(generation)) { _loading = false; } } } @@ -164,6 +172,7 @@ private async Task Close() { _built = false; + _openGeneration++; _csv = ""; _dataUri = ""; Visible = false; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvImportModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvImportModal.razor index b755ed7c..cf530f97 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvImportModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvImportModal.razor @@ -191,6 +191,11 @@ private bool _committing; private bool _resolved; + // Bumped on every open and every close; an async continuation compares its captured value after each + // await so a superseded open can never write its target under a different device's displayed modal + // (same idiom as RawBrowseModal / RawManualTagEntryModal). + private int _openGeneration; + private string _effectiveDeviceId = ""; private string? _groupPrefix; @@ -209,17 +214,21 @@ { if (Visible && !_resolved) { - await ResolveTargetAsync(); _resolved = true; + var generation = ++_openGeneration; + await ResolveTargetAsync(generation); } else if (!Visible) { _resolved = false; + _openGeneration++; } } + private bool IsStale(int generation) => generation != _openGeneration || !Visible; + // Resolve the effective device id + the group-path prefix once per open. - private async Task ResolveTargetAsync() + private async Task ResolveTargetAsync(int generation) { _effectiveDeviceId = DeviceId; _groupPrefix = null; @@ -227,6 +236,7 @@ if (!string.IsNullOrEmpty(TagGroupId)) { var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!); + if (IsStale(generation)) { return; } if (ctx is { } c) { _groupPrefix = c.GroupPath; @@ -244,12 +254,14 @@ _commitErrors = new(); _removed.Clear(); _parsing = true; + var generation = _openGeneration; try { using var stream = e.File.OpenReadStream(maxAllowedSize: 16 * 1024 * 1024); using var reader = new StreamReader(stream); var text = await reader.ReadToEndAsync(); + if (IsStale(generation)) { return; } _result = RawTagCsvMapper.Parse(DriverType, text, _groupPrefix); _fatal = _result.FatalError; } @@ -283,6 +295,8 @@ _committing = true; _commitErrors = new(); + var generation = _openGeneration; + var targetDeviceId = _effectiveDeviceId; try { var rows = _result.Rows @@ -290,7 +304,8 @@ .Select(r => r.ImportRow!) .ToList(); - var outcome = await Svc.ImportTagsAsync(_effectiveDeviceId, rows); + var outcome = await Svc.ImportTagsAsync(targetDeviceId, rows); + if (IsStale(generation)) { return; } if (outcome.Errors.Count > 0) { _commitErrors = outcome.Errors.ToList(); @@ -315,6 +330,7 @@ Reset(); Visible = false; _resolved = false; + _openGeneration++; await VisibleChanged.InvokeAsync(false); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddReferenceModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddReferenceModal.razor index bb2709b0..e35536f6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddReferenceModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddReferenceModal.razor @@ -80,11 +80,20 @@ private string? _error; private bool _wasVisible; + // Bumped on every open and close so a superseded picker-root load can't install its tree under a + // different equipment's (or a re-opened) modal (RawBrowseModal idiom). + private int _openGeneration; + + private bool IsStale(int generation) => generation != _openGeneration || !Visible; + protected override async Task OnParametersSetAsync() { // Reset + reload only on the false→true transition so re-renders while open don't wipe state. + if (!Visible && _wasVisible) { _openGeneration++; } if (Visible && !_wasVisible) { + _wasVisible = true; + var generation = ++_openGeneration; _selected.Clear(); _error = null; _roots = Array.Empty(); @@ -94,6 +103,7 @@ var root = string.IsNullOrEmpty(EquipmentId) ? null : await Svc.LoadReferencePickerRootAsync(EquipmentId); + if (IsStale(generation)) { return; } _roots = root is null ? Array.Empty() : new[] { root }; _loading = false; } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/AdminUiFormSweepGuardTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/AdminUiFormSweepGuardTests.cs new file mode 100644 index 00000000..163a60cf --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/AdminUiFormSweepGuardTests.cs @@ -0,0 +1,60 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw; + +/// +/// Issue #604 source-scan guards (Razor has no bUnit): an EditForm with a validator but nothing +/// to show its messages is a silent no-op Save, the layout must keep its error boundary, and the +/// API-key Delete must not fire straight from the row button. +/// +public sealed class AdminUiFormSweepGuardTests +{ + /// EditForm pages whose validation messages are rendered by a child/section component. + private static readonly HashSet AllowList = new(StringComparer.Ordinal); + + [Fact] + public void Every_razor_with_an_EditForm_and_a_DataAnnotationsValidator_renders_validation_messages() + { + var offenders = new List(); + foreach (var file in Directory.EnumerateFiles(ComponentsDir(), "*.razor", SearchOption.AllDirectories)) + { + var text = File.ReadAllText(file); + if (!text.Contains(" +/// 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 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(); + } +}