0f1f8ad8fa
I1: /hosts eviction keyed off the AUTHORED DriverInstances, so a driver deleted in /raw but not yet deployed - still running, still publishing - vanished every tick. The stores' sweep is now EvictUnconfiguredStale(set, olderThanUtc): an entry goes only when unconfigured AND its PublishedUtc is older than 60 s. Tests cover unconfigured+fresh kept (positive control), unconfigured+stale evicted, configured+stale kept, and the deploy hand-off. M1: Test Connect falls back to the driver's documented ProbeTimeoutSeconds default (Galaxy 30, OpcUaClient 15, ...) when the key is absent. M2: the budget never undercuts the config's root timeoutMs (+1 s). M5: RawBrowseModal compares the open generation captured before the await, so a close-then-reopen mid-open closes the stale session. M6: /alarms-historian keeps a 30 s re-poll on 'not registered' (a driver node may still be starting) instead of stopping for good. M7: /scripts source fetch failure shows in the dialog, not the error boundary. M8: the export modal maps only its preview rows; the endpoint writes via Results.Text (no second byte[] copy). M9: /hosts config queries are ordered, so SequenceEqual change detection holds. N1 tooltip, N2 CRLF-safe regex, N3 throttled Warning for subscriber faults, N5 equipment-import help text now describes RFC 4180 quoting. H3: MonacoEditor documents that themes are page-global. Refs #745 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
179 lines
7.8 KiB
C#
179 lines
7.8 KiB
C#
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
|
|
|
|
/// <summary>
|
|
/// G2-F3: after a rebirth request the browse modal re-reads the tree itself (the session's
|
|
/// <c>RequestRebirthAsync</c> now waits for the births), instead of telling the operator to re-expand it.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class RawBrowseRebirthRefreshTests : IDisposable
|
|
{
|
|
private const BindingFlags Private = BindingFlags.NonPublic | BindingFlags.Instance;
|
|
private readonly List<Renderer> _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<int>(modal, "_treeGeneration");
|
|
|
|
await ConfirmAsync(modal);
|
|
|
|
Get<int>(modal, "_treeGeneration").ShouldBe(before + 1);
|
|
Get<string?>(modal, "_rebirthOutcome")!.ShouldContain("The tree was refreshed");
|
|
Get<object?>(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<int>();
|
|
var svc = new RebirthBrowserService { Result = gate.Task };
|
|
var modal = NewOpenModal(svc);
|
|
var before = Get<int>(modal, "_treeGeneration");
|
|
|
|
var confirm = ConfirmAsync(modal);
|
|
typeof(RawBrowseModal).GetProperty("Visible")!.SetValue(modal, false);
|
|
gate.SetResult(1);
|
|
await confirm;
|
|
|
|
Get<int>(modal, "_treeGeneration").ShouldBe(before);
|
|
Get<string?>(modal, "_rebirthOutcome")!.ShouldNotContain("refreshed");
|
|
// Positive control: the publish itself was still made and reported.
|
|
svc.Scopes.ShouldBe(["Grp/EdgeA"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_open_superseded_by_a_close_and_reopen_closes_its_own_session()
|
|
{
|
|
// Review r0 M5: Visible is true again after a reopen, so only the generation identifies the stale open.
|
|
var gate = new TaskCompletionSource<BrowseOpenResult>();
|
|
var svc = new RebirthBrowserService { Result = Task.FromResult(0), Open = () => gate.Task };
|
|
var modal = NewOpenModal(svc);
|
|
Set(modal, "_token", Guid.Empty);
|
|
|
|
var open = (Task)typeof(RawBrowseModal).GetMethod("OpenBrowseAsync", Private)!.Invoke(modal, null)!;
|
|
Set(modal, "_openGeneration", Get<int>(modal, "_openGeneration") + 1); // closed + reopened meanwhile
|
|
var stale = Guid.NewGuid();
|
|
gate.SetResult(new BrowseOpenResult(true, null, stale));
|
|
await open;
|
|
|
|
await svc.Closed.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
|
svc.ClosedTokens.ShouldBe([stale]);
|
|
Get<Guid>(modal, "_token").ShouldBe(Guid.Empty);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_current_open_adopts_its_session()
|
|
{
|
|
// Positive control for the test above.
|
|
var token = Guid.NewGuid();
|
|
var svc = new RebirthBrowserService { Result = Task.FromResult(0), Open = () => Task.FromResult(new BrowseOpenResult(true, null, token)) };
|
|
var modal = NewOpenModal(svc);
|
|
Set(modal, "_token", Guid.Empty);
|
|
|
|
await (Task)typeof(RawBrowseModal).GetMethod("OpenBrowseAsync", Private)!.Invoke(modal, null)!;
|
|
|
|
Get<Guid>(modal, "_token").ShouldBe(token);
|
|
svc.ClosedTokens.ShouldBeEmpty();
|
|
}
|
|
|
|
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<T>(RawBrowseModal modal, string field) =>
|
|
(T)typeof(RawBrowseModal).GetField(field, Private)!.GetValue(modal)!;
|
|
|
|
private sealed class RebirthBrowserService : IBrowserSessionService
|
|
{
|
|
public required Task<int> Result { get; init; }
|
|
public Func<Task<BrowseOpenResult>> Open { get; init; } = () => throw new NotImplementedException();
|
|
public List<string> Scopes { get; } = new();
|
|
public List<Guid> ClosedTokens { get; } = new();
|
|
public TaskCompletionSource Closed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
public bool CanBrowse(string driverType, string configJson) => true;
|
|
public Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct) => Open();
|
|
public Task<IReadOnlyList<BrowseNode>> RootAsync(Guid token, CancellationToken ct) => throw new NotImplementedException();
|
|
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(Guid token, string nodeId, CancellationToken ct) => throw new NotImplementedException();
|
|
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct) => throw new NotImplementedException();
|
|
public bool CanRequestRebirth(Guid token) => true;
|
|
public Task CloseAsync(Guid token)
|
|
{
|
|
lock (ClosedTokens) ClosedTokens.Add(token);
|
|
Closed.TrySetResult();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<int> 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<Task> workItem) => workItem();
|
|
public override Task<TResult> InvokeAsync<TResult>(Func<TResult> workItem) => Task.FromResult(workItem());
|
|
public override Task<TResult> InvokeAsync<TResult>(Func<Task<TResult>> workItem) => workItem();
|
|
}
|
|
}
|