Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Health/HealthProbeActorTests.cs
T
Joseph Doherty 55ae230fa4 ci(#606): Gitea-compatible trx upload + deterministic fixes for the CI-load test flakes
- v2-ci.yml: upload-artifact@v4 -> @v3 + continue-on-error (act_runner reports as GHES and
  v4 refuses GHES, failing every run); runner-capacity diagnostic step; AKKA_TEST_TIMEFACTOR=3
  on the test step.
- Runtime/ControlPlane harnesses: akka.test.timefactor from AKKA_TEST_TIMEFACTOR
  (TestTimeFactor), single-expect-default 15s; ControlPlane gains PresenceBudget.
- Leaked spinner threads: the infinite-loop timeout tests (Calculation, Host.IntegrationTests
  RoslynVirtualTagEvaluator, 3x Core.Scripting) pinned a core for the rest of the test process
  (abandoned, never stopped). Now a bounded 5 s wall-clock spin; outer compile-inclusive bound 30 s.
- Real test races fixed: ScriptedAlarmEngine push-during-drain (Inactive is also the start
  state + persist-before-update => wait for the Cleared save); DriverReconnectE2e (both nodes
  host the driver; Created[] is last-writer-wins and ForceReconnect is a no-op while
  Connecting); ControlPlaneStarterOrder (lost-race heal waited for a 60 s refresh tick).
- Fixed sleeps -> poll-for-condition: ReconnectSupervisor LastError, Modbus deadband steps
  (wait for observed reads), Modbus dispose hygiene precondition, FOCAS probe-loop log.
- Short presence budgets -> PresenceBudget in the Runtime/ControlPlane tests seen failing on
  Gitea; Calculation Settle 10 -> 30 s. No assertion removed, nothing skipped.
- infra/README.md §10 CI; stale TreatWarningsAsErrors comment in Directory.Build.props.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 19:44:31 -04:00

137 lines
5.5 KiB
C#

using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Health;
public sealed class HealthProbeActorTests : RuntimeActorTestBase
{
/// <summary>Verifies that the DB health probe actor returns reachable status against an in-memory database.</summary>
[Fact]
public async Task DbHealthProbeActor_returns_reachable_against_in_memory_db()
{
var db = NewInMemoryDbFactory();
var actor = Sys.ActorOf(DbHealthProbeActor.Props(db));
var status = await actor.Ask<DbHealthProbeActor.DbHealthStatus>(
DbHealthProbeActor.GetStatus.Instance, PresenceBudget);
status.Reachable.ShouldBeTrue();
status.LastError.ShouldBeNull();
}
/// <summary>Verifies that the peer OPC UA probe actor reports Ok true against a live listener.</summary>
[Fact]
public void PeerOpcUaProbeActor_reports_Ok_true_against_a_live_listener()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var received = new System.Collections.Generic.List<object>();
Sys.ActorOf(PeerOpcUaProbeActor.Props(
NodeId.Parse($"127.0.0.1:{port}"),
interval: TimeSpan.FromMilliseconds(50),
connectTimeout: TimeSpan.FromMilliseconds(500),
opcUaPort: port,
broadcast: msg => received.Add(msg)));
AwaitCondition(() => received.OfType<PeerOpcUaProbeActor.OpcUaProbeResult>().Any(r => r.Ok),
TimeSpan.FromSeconds(3));
}
/// <summary>Verifies that the peer OPC UA probe actor reports Ok false against an unreachable endpoint.</summary>
[Fact]
public void PeerOpcUaProbeActor_reports_Ok_false_against_an_unreachable_endpoint()
{
// Port 1 is reserved (tcpmux) and almost never bound on dev machines, so the connect fails fast.
var received = new System.Collections.Generic.List<object>();
Sys.ActorOf(PeerOpcUaProbeActor.Props(
NodeId.Parse("127.0.0.1:1"),
interval: TimeSpan.FromMilliseconds(50),
connectTimeout: TimeSpan.FromMilliseconds(300),
opcUaPort: 1,
broadcast: msg => received.Add(msg)));
AwaitCondition(() => received.OfType<PeerOpcUaProbeActor.OpcUaProbeResult>().Any(r => !r.Ok),
TimeSpan.FromSeconds(3));
}
/// <summary>Verifies that the historian adapter actor forwards events to the injected sink.</summary>
[Fact]
public void HistorianAdapterActor_forwards_events_to_injected_sink()
{
var sink = new RecordingSink();
var actor = Sys.ActorOf(HistorianAdapterActor.Props(sink));
for (var i = 0; i < 5; i++)
actor.Tell(new AlarmHistorianEvent(
AlarmId: $"alm-{i}",
EquipmentPath: "Plant/LineA",
AlarmName: $"Alarm{i}",
AlarmTypeName: "LimitAlarm",
Severity: AlarmSeverity.High,
EventKind: "Activated",
Message: $"Test alarm {i}",
User: "system",
Comment: null,
TimestampUtc: DateTime.UtcNow));
AwaitCondition(() => sink.Enqueued.Count == 5, TimeSpan.FromSeconds(2));
sink.Enqueued.Select(e => e.AlarmId).OrderBy(s => s).ShouldBe(
new[] { "alm-0", "alm-1", "alm-2", "alm-3", "alm-4" });
}
/// <summary>Verifies that the historian adapter actor returns sink status via GetStatus.</summary>
[Fact]
public async Task HistorianAdapterActor_returns_sink_status_via_GetStatus()
{
var sink = new RecordingSink();
var actor = Sys.ActorOf(HistorianAdapterActor.Props(sink));
actor.Tell(new AlarmHistorianEvent(
"alm-x", "Plant/LineB", "OffNormal", "OffNormalAlarm",
AlarmSeverity.Low, "Activated", "msg", "system", null, DateTime.UtcNow));
AwaitCondition(() => sink.Enqueued.Count == 1, TimeSpan.FromSeconds(2));
var status = await actor.Ask<HistorianSinkStatus>(
HistorianAdapterActor.GetStatus.Instance, TimeSpan.FromSeconds(2));
status.QueueDepth.ShouldBe(1);
status.DrainState.ShouldBe(HistorianDrainState.Idle);
}
private sealed class RecordingSink : IAlarmHistorianSink
{
/// <summary>Gets the list of enqueued alarm historian events.</summary>
public ConcurrentBag<AlarmHistorianEvent> Enqueued { get; } = [];
/// <summary>Enqueues an alarm historian event.</summary>
/// <param name="evt">The event to enqueue.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken)
{
Enqueued.Add(evt);
return Task.CompletedTask;
}
/// <summary>Gets the current status of the historian sink.</summary>
public HistorianSinkStatus GetStatus() => new(
QueueDepth: Enqueued.Count,
DeadLetterDepth: 0,
LastDrainUtc: null,
LastSuccessUtc: null,
LastError: null,
DrainState: HistorianDrainState.Idle);
}
}