Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AlarmsHistorianReaderTests.cs
T
Joseph Doherty 0f1f8ad8fa fix(adminui): lane D-b review r0 fixes (I1, M1, M2, M5-M9, N1-N3, N5, H3)
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
2026-10-22 18:21:46 -04:00

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");
}
}