using Akka.Actor; using Akka.Hosting; using Akka.TestKit.Xunit; 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; /// /// R04 L3: /alarms-historian 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. /// [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(_ => 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(), 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(cancellationToken: TestContext.Current.CancellationToken); } [Fact] public async Task Page_teardown_cancellation_propagates() { var silent = CreateTestProbe(); using var cts = new CancellationTokenSource(); cts.Cancel(); await Should.ThrowAsync(() => 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"); } }