diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor index b9a1f03f..433d5533 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor @@ -683,13 +683,29 @@ _rebirthOutcome = null; _rebirthError = null; StateHasChanged(); + var token = _token; + var target = _rebirthTarget; try { - var published = await BrowserService.RequestRebirthAsync(_token, _rebirthTarget.Scope, default); + var published = await BrowserService.RequestRebirthAsync(token, target.Scope, default); + // The session's RequestRebirthAsync returns only once the births it asked for have arrived and + // gone quiet (bounded by the window), so the tree can be re-read now instead of telling the + // operator to re-expand it later (G2-F3). Skipped if the session or modal went away meanwhile. + var refreshed = token == _token && Visible && !_disposed; + if (refreshed) + { + // Re-key the tree against the SAME session (as the Refresh button does). The armed scope is + // dropped — its node may not be in the rebuilt tree — but the outcome line is kept. + _treeGeneration++; + _rebirthTarget = null; + } + _rebirthOutcome = - $"Rebirth requested for {_rebirthTarget.Kind} — {published} command" + $"Rebirth requested for {target.Kind} — {published} command" + (published == 1 ? "" : "s") - + " published. Re-expand the tree in a few seconds to see the republished metrics."; + + (refreshed + ? " published. The tree was refreshed with the births that arrived; press Refresh if more appear later." + : " published."); _rebirthConfirming = false; } catch (Exception ex) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawBrowseRebirthRefreshTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawBrowseRebirthRefreshTests.cs new file mode 100644 index 00000000..4b38fbbe --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawBrowseRebirthRefreshTests.cs @@ -0,0 +1,135 @@ +using System.Reflection; +using System.Runtime.ExceptionServices; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.RenderTree; +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.Commons.Browsing; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw; + +// BL0006: the test renderer needs RenderTree types (same suppression as RawBrowseModalStaleOpenTests). +#pragma warning disable BL0006 + +/// +/// G2-F3: after a rebirth request the browse modal re-reads the tree itself (the session's +/// RequestRebirthAsync now waits for the births), instead of telling the operator to re-expand it. +/// +[Trait("Category", "Unit")] +public sealed class RawBrowseRebirthRefreshTests : IDisposable +{ + private const BindingFlags Private = BindingFlags.NonPublic | BindingFlags.Instance; + private readonly List _renderers = new(); + + public void Dispose() + { + foreach (var r in _renderers) r.Dispose(); + } + + [Fact] + public async Task A_completed_rebirth_rekeys_the_tree_and_keeps_the_outcome() + { + var svc = new RebirthBrowserService { Result = Task.FromResult(2) }; + var modal = NewOpenModal(svc); + var before = Get(modal, "_treeGeneration"); + + await ConfirmAsync(modal); + + Get(modal, "_treeGeneration").ShouldBe(before + 1); + Get(modal, "_rebirthOutcome")!.ShouldContain("The tree was refreshed"); + Get(modal, "_rebirthTarget").ShouldBeNull(); + svc.Scopes.ShouldBe(["Grp/EdgeA"]); + } + + [Fact] + public async Task A_modal_closed_during_the_wait_is_not_refreshed() + { + var gate = new TaskCompletionSource(); + var svc = new RebirthBrowserService { Result = gate.Task }; + var modal = NewOpenModal(svc); + var before = Get(modal, "_treeGeneration"); + + var confirm = ConfirmAsync(modal); + typeof(RawBrowseModal).GetProperty("Visible")!.SetValue(modal, false); + gate.SetResult(1); + await confirm; + + Get(modal, "_treeGeneration").ShouldBe(before); + Get(modal, "_rebirthOutcome")!.ShouldNotContain("refreshed"); + // Positive control: the publish itself was still made and reported. + svc.Scopes.ShouldBe(["Grp/EdgeA"]); + } + + private RawBrowseModal NewOpenModal(IBrowserSessionService svc) + { + var modal = new RawBrowseModal(); + typeof(RawBrowseModal).GetProperty("BrowserService", Private)!.SetValue(modal, svc); + typeof(RawBrowseModal).GetProperty("Visible")!.SetValue(modal, true); + Set(modal, "_token", Guid.NewGuid()); + Set(modal, "_canRebirth", true); + + var targetType = typeof(RawBrowseModal).GetNestedType("RebirthTarget", BindingFlags.NonPublic)!; + Set(modal, "_rebirthTarget", Activator.CreateInstance(targetType, "Grp/EdgeA", "edge node EdgeA", "detail", false)); + Set(modal, "_rebirthConfirming", true); + + var renderer = new InertRenderer(); + _renderers.Add(renderer); + renderer.Attach(modal); + return modal; + } + + private static Task ConfirmAsync(RawBrowseModal modal) => + (Task)typeof(RawBrowseModal).GetMethod("ConfirmRebirthAsync", Private)!.Invoke(modal, null)!; + + private static void Set(RawBrowseModal modal, string field, object? value) + { + var f = typeof(RawBrowseModal).GetField(field, Private); + f.ShouldNotBeNull($"{field} was renamed"); + f!.SetValue(modal, value); + } + + private static T Get(RawBrowseModal modal, string field) => + (T)typeof(RawBrowseModal).GetField(field, Private)!.GetValue(modal)!; + + private sealed class RebirthBrowserService : IBrowserSessionService + { + public required Task Result { get; init; } + public List Scopes { get; } = new(); + + 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 bool CanRequestRebirth(Guid token) => true; + public Task CloseAsync(Guid token) => Task.CompletedTask; + + public Task RequestRebirthAsync(Guid token, string scope, CancellationToken ct) + { + Scopes.Add(scope); + return Result; + } + } + + private sealed class InertRenderer() + : Renderer(new ServiceCollection().BuildServiceProvider(), NullLoggerFactory.Instance) + { + public override Dispatcher Dispatcher { get; } = new InlineDispatcher(); + 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(); + } +}