Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AlarmsHistorianReaderTests.cs
T
Joseph Doherty 1313b94097 fix(adminui): /alarms-historian says when the role runs no historian; Fleet-style lifecycle (R04 L3)
A bare catch {} turned every failure - including an admin-only node with no
historian actor - into 'Loading...' forever, and a tick queued after a sync
timer dispose still issued an Ask. HistorianSinkStatusReader classifies a poll
as Available / NotOnThisRole (permanent: shown once, polling stops) /
Unavailable (shown with the last good status and its read time). The page
follows Fleet.razor: IAsyncDisposable, _disposed, overlap guard, page CTS.

Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 17:47:30 -04:00

86 lines
3.3 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 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");
src.ShouldNotContain("catch\n {");
}
}