1313b94097
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
86 lines
3.3 KiB
C#
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 {");
|
|
}
|
|
}
|