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
101 lines
4.2 KiB
C#
101 lines
4.2 KiB
C#
using Akka.Actor;
|
|
using Akka.Hosting;
|
|
using Akka.TestKit.Xunit2;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
|
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
|
|
|
/// <summary>
|
|
/// R04 L3: <c>/alarms-historian</c> distinguishes "this role runs no historian" (a permanent state, shown
|
|
/// once, not polled) from a sink that did not answer, instead of swallowing both into "Loading…" forever.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class AlarmsHistorianReaderTests : TestKit
|
|
{
|
|
private static readonly HistorianSinkStatus Status = new(3, 1, null, null, null, HistorianDrainState.Idle);
|
|
|
|
private sealed class StatusActor : ReceiveActor
|
|
{
|
|
public StatusActor() => Receive<HistorianAdapterActor.GetStatus>(_ => Sender.Tell(Status));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_answering_sink_is_available()
|
|
{
|
|
var actor = Sys.ActorOf(Props.Create(() => new StatusActor()));
|
|
|
|
var reading = await HistorianSinkStatusReader.ReadAsync(() => actor, TimeSpan.FromSeconds(5), CancellationToken.None);
|
|
|
|
reading.Availability.ShouldBe(HistorianSinkAvailability.Available);
|
|
reading.Status.ShouldBe(Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_node_without_the_registry_entry_reports_not_on_this_role()
|
|
{
|
|
// The real Akka.Hosting registry: an admin-only node never registers the historian key.
|
|
var registry = new ActorRegistry();
|
|
|
|
var reading = await HistorianSinkStatusReader.ReadAsync(
|
|
() => registry.Get<HistorianAdapterActorKey>(), TimeSpan.FromSeconds(5), CancellationToken.None);
|
|
|
|
reading.Availability.ShouldBe(HistorianSinkAvailability.NotOnThisRole);
|
|
reading.Status.ShouldBeNull();
|
|
reading.Message!.ShouldContain("driver role");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_silent_sink_is_unavailable_not_loading()
|
|
{
|
|
var silent = CreateTestProbe(); // receives the Ask, never replies
|
|
|
|
var reading = await HistorianSinkStatusReader.ReadAsync(() => silent.Ref, TimeSpan.FromMilliseconds(200), CancellationToken.None);
|
|
|
|
reading.Availability.ShouldBe(HistorianSinkAvailability.Unavailable);
|
|
reading.Message!.ShouldContain("did not answer");
|
|
// Positive control: the Ask really was delivered — the classification is about the missing reply.
|
|
silent.ExpectMsg<HistorianAdapterActor.GetStatus>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Page_teardown_cancellation_propagates()
|
|
{
|
|
var silent = CreateTestProbe();
|
|
using var cts = new CancellationTokenSource();
|
|
cts.Cancel();
|
|
|
|
await Should.ThrowAsync<OperationCanceledException>(() =>
|
|
HistorianSinkStatusReader.ReadAsync(() => silent.Ref, TimeSpan.FromSeconds(30), cts.Token));
|
|
}
|
|
|
|
[Fact]
|
|
public void Not_registered_keeps_a_slow_repoll_instead_of_stopping()
|
|
{
|
|
// Review r0 M6: a driver node opened mid-startup is "not registered" for a moment — never permanent.
|
|
HistorianSinkStatusReader.PollIntervalFor(HistorianSinkAvailability.NotOnThisRole)
|
|
.ShouldBe(HistorianSinkStatusReader.NotRegisteredPollInterval);
|
|
HistorianSinkStatusReader.PollIntervalFor(HistorianSinkAvailability.Available)
|
|
.ShouldBe(HistorianSinkStatusReader.PollInterval);
|
|
HistorianSinkStatusReader.PollIntervalFor(null).ShouldBe(HistorianSinkStatusReader.PollInterval);
|
|
|
|
var src = AdminUiSource.Read("Components", "Pages", "AlarmsHistorian.razor");
|
|
src.ShouldContain("_timer.Change(interval, interval)");
|
|
src.ShouldNotContain("if (_reading?.Availability == HistorianSinkAvailability.NotOnThisRole) return;");
|
|
}
|
|
|
|
[Fact]
|
|
public void Page_uses_the_reader_and_the_async_dispose_lifecycle()
|
|
{
|
|
var src = AdminUiSource.Read("Components", "Pages", "AlarmsHistorian.razor");
|
|
src.ShouldContain("HistorianSinkStatusReader.ReadAsync(");
|
|
src.ShouldContain("@implements IAsyncDisposable");
|
|
src.ShouldContain("HistorianSinkAvailability.NotOnThisRole");
|
|
System.Text.RegularExpressions.Regex.IsMatch(src, @"catch\s*\{").ShouldBeFalse("a bare catch {} swallows every failure");
|
|
}
|
|
}
|