using System.Collections.Concurrent; using Akka.Actor; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Runtime.Health; using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa; public sealed class OpcUaPublishActorTests : RuntimeActorTestBase { /// Verifies that message contracts are accepted without a pinned dispatcher in tests. [Fact] public void Accepts_message_contracts_without_pinned_dispatcher_in_tests() { var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests()); actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=Tag1", 42.0, OpcUaQuality.Good, DateTime.UtcNow)); actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=Alarm1", Snapshot(active: true), DateTime.UtcNow)); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(240)); ExpectNoMsg(TimeSpan.FromMilliseconds(200)); } /// Verifies that production props target the OPC UA synchronized dispatcher. [Fact] public void Production_Props_targets_opcua_synchronized_dispatcher() { var props = OpcUaPublishActor.Props(); props.Dispatcher.ShouldBe(OpcUaPublishActor.DispatcherId); } /// Verifies that AttributeValueUpdate routes to sink WriteValue. [Fact] public void AttributeValueUpdate_routes_to_sink_WriteValue() { var sink = new RecordingSink(); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink)); actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T1", 3.14, OpcUaQuality.Good, DateTime.UtcNow)); actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T2", "abc", OpcUaQuality.Uncertain, DateTime.UtcNow)); AwaitAssert(() => { sink.Values.Count.ShouldBe(2); sink.Values[0].NodeId.ShouldBe("ns=2;s=T1"); sink.Values[0].Value.ShouldBe(3.14); sink.Values[0].Quality.ShouldBe(OpcUaQuality.Good); sink.Values[1].Quality.ShouldBe(OpcUaQuality.Uncertain); }, duration: TimeSpan.FromMilliseconds(500)); } /// Verifies that AlarmStateUpdate routes to sink WriteAlarmCondition with the full snapshot. [Fact] public void AlarmStateUpdate_routes_to_sink_WriteAlarmCondition() { var sink = new RecordingSink(); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink)); actor.Tell(new OpcUaPublishActor.AlarmStateUpdate( "ns=2;s=A1", Snapshot(active: true, acknowledged: false, severity: 700), DateTime.UtcNow)); AwaitAssert(() => { sink.Alarms.Count.ShouldBe(1); sink.Alarms[0].AlarmNodeId.ShouldBe("ns=2;s=A1"); sink.Alarms[0].State.Active.ShouldBeTrue(); sink.Alarms[0].State.Acknowledged.ShouldBeFalse(); sink.Alarms[0].State.Severity.ShouldBe((ushort)700); }, duration: TimeSpan.FromMilliseconds(500)); } /// Builds a test with sensible defaults so each test /// only specifies the fields it cares about. private static AlarmConditionSnapshot Snapshot( bool active = false, bool acknowledged = true, bool confirmed = true, bool enabled = true, AlarmShelvingKind shelving = AlarmShelvingKind.Unshelved, ushort severity = 500, string message = "test") => new(active, acknowledged, confirmed, enabled, shelving, severity, message); /// Verifies that RebuildAddressSpace calls sink Rebuild. [Fact] public void RebuildAddressSpace_calls_sink_Rebuild() { var sink = new RecordingSink(); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink)); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); } /// Verifies that ServiceLevelChanged publishes to IServiceLevelPublisher once per unique level. [Fact] public void ServiceLevelChanged_publishes_to_IServiceLevelPublisher_once_per_unique_level() { var publisher = new RecordingPublisher(); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(serviceLevel: publisher)); actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(240)); actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(240)); // dedup actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(100)); AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 100 }), duration: TimeSpan.FromMilliseconds(500)); } /// Verifies that the very first computed ServiceLevel is always published even when it is /// 0 — a node starting Detached (or role-less) must publish 0 rather than let the SDK default /// (255 = full service) stand. The dedup against the byte-default 0 must not swallow /// the first publish. Drives a Detached local entry (first computed level = 0) with no DB-health /// probe and asserts the publisher saw exactly [0]. [Fact] public void First_service_level_zero_is_published_not_deduped() { var publisher = new RecordingPublisher(); var local = NodeId.Parse("detached-node"); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( serviceLevel: publisher, localNode: local)); actor.Tell(new RedundancyStateChanged( Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Detached, IsClusterLeader: false, IsRoleLeaderForDriver: false, DateTime.UtcNow), }, CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 0 }), duration: TimeSpan.FromMilliseconds(500)); } /// Verifies that RedundancyStateChanged drives local ServiceLevel publish for primary leader. [Fact] public void RedundancyStateChanged_drives_local_ServiceLevel_publish_for_primary_leader() { var publisher = new RecordingPublisher(); var local = NodeId.Parse("primary-node"); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( serviceLevel: publisher, localNode: local)); var snapshot = new RedundancyStateChanged( Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Primary, IsClusterLeader: true, IsRoleLeaderForDriver: true, DateTime.UtcNow), new NodeRedundancyState(NodeId.Parse("other-node"), RedundancyRole.Secondary, IsClusterLeader: false, IsRoleLeaderForDriver: false, DateTime.UtcNow), }, CorrelationId.NewId()); actor.Tell(snapshot); AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240 }), duration: TimeSpan.FromMilliseconds(500)); } /// Verifies that RedundancyStateChanged for secondary publishes 100. [Fact] public void RedundancyStateChanged_for_secondary_publishes_100() { var publisher = new RecordingPublisher(); var local = NodeId.Parse("secondary-node"); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(serviceLevel: publisher, localNode: local)); var snapshot = new RedundancyStateChanged( Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Secondary, IsClusterLeader: false, IsRoleLeaderForDriver: false, DateTime.UtcNow), }, CorrelationId.NewId()); actor.Tell(snapshot); AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 100 }), duration: TimeSpan.FromMilliseconds(500)); } /// Verifies that the calculator path computes 250 for a healthy primary role-leader /// (basis 240 from DB-reachable + probe-ok + fresh, +10 driver-role-leader bonus). [Fact] public void Calculator_path_healthy_primary_leader_publishes_250() { var publisher = new RecordingPublisher(); var local = NodeId.Parse("primary-node"); var probe = Sys.ActorOf(Akka.Actor.Props.Create(() => new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null)))); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( serviceLevel: publisher, localNode: local, dbHealthProbe: probe)); actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null)); actor.Tell(new RedundancyStateChanged( Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Primary, IsClusterLeader: true, IsRoleLeaderForDriver: true, DateTime.UtcNow), }, CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), duration: TimeSpan.FromMilliseconds(500)); } /// Verifies that the calculator path computes 240 for a healthy non-leader secondary /// (basis 240 from DB-reachable + probe-ok + fresh, no leader bonus). Documented change from the /// legacy role-only path, which mapped Secondary → 100. [Fact] public void Calculator_path_healthy_secondary_publishes_240() { var publisher = new RecordingPublisher(); var local = NodeId.Parse("secondary-node"); var probe = Sys.ActorOf(Akka.Actor.Props.Create(() => new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null)))); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( serviceLevel: publisher, localNode: local, dbHealthProbe: probe)); actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null)); actor.Tell(new RedundancyStateChanged( Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Secondary, IsClusterLeader: false, IsRoleLeaderForDriver: false, DateTime.UtcNow), }, CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), duration: TimeSpan.FromMilliseconds(500)); } /// Verifies that the calculator path computes 100 when the DB is unreachable /// ((false,_,true) basis, stale via !DbReachable) for a non-leader secondary (no bonus). [Fact] public void Calculator_path_db_unreachable_publishes_100() { var publisher = new RecordingPublisher(); var local = NodeId.Parse("secondary-node"); var probe = Sys.ActorOf(Akka.Actor.Props.Create(() => new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(false, DateTime.UtcNow, "down")))); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( serviceLevel: publisher, localNode: local, dbHealthProbe: probe)); actor.Tell(new DbHealthProbeActor.DbHealthStatus(false, DateTime.UtcNow, "down")); actor.Tell(new RedundancyStateChanged( Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Secondary, IsClusterLeader: false, IsRoleLeaderForDriver: false, DateTime.UtcNow), }, CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)100), duration: TimeSpan.FromMilliseconds(500)); } /// Verifies that the calculator path computes 200 for a stale snapshot when the DB is /// reachable + fresh but the redundancy entry's AsOfUtc is older than the stale window /// ((true,_,true) basis) for a non-leader secondary (no bonus). [Fact] public void Calculator_path_stale_snapshot_publishes_200() { var publisher = new RecordingPublisher(); var local = NodeId.Parse("secondary-node"); var probe = Sys.ActorOf(Akka.Actor.Props.Create(() => new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null)))); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( serviceLevel: publisher, localNode: local, dbHealthProbe: probe, staleWindow: TimeSpan.FromSeconds(2))); actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null)); actor.Tell(new RedundancyStateChanged( Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Secondary, IsClusterLeader: false, IsRoleLeaderForDriver: false, DateTime.UtcNow - TimeSpan.FromMinutes(1)), }, CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)200), duration: TimeSpan.FromMilliseconds(500)); } /// Verifies that a detached local node publishes 0 (the calculator does not model /// Detached, so the handler guards it before the calculator path). The node first goes healthy /// (250) so the dedup'd transition down to 0 is observable. [Fact] public void Calculator_path_detached_publishes_0() { var publisher = new RecordingPublisher(); var local = NodeId.Parse("detached-node"); var probe = Sys.ActorOf(Akka.Actor.Props.Create(() => new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null)))); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( serviceLevel: publisher, localNode: local, dbHealthProbe: probe)); actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null)); // First go healthy primary-leader (250) so the transition down to 0 is not dedup'd against // the initial 0. actor.Tell(new RedundancyStateChanged( Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Primary, IsClusterLeader: true, IsRoleLeaderForDriver: true, DateTime.UtcNow), }, CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), duration: TimeSpan.FromMilliseconds(500)); // Now detach — expect the guard to drive ServiceLevel down to 0. actor.Tell(new RedundancyStateChanged( Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Detached, IsClusterLeader: false, IsRoleLeaderForDriver: false, DateTime.UtcNow), }, CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)0), duration: TimeSpan.FromMilliseconds(500)); } /// Verifies that an actively-observed, recent peer probe of MY endpoint that came back /// Ok==false demotes the calculator basis to 0. A non-leader Secondary entry (no +10 bonus) /// is used so the assertion is unambiguous: inputs (DbReachable=true, OpcUaProbeOk=false, /// Stale=false) match Compute's _ => 0 arm → 0, +0 (non-leader) → 0. [Fact] public void Probe_false_about_me_with_healthy_db_publishes_0() { var publisher = new RecordingPublisher(); var local = NodeId.Parse("secondary-node"); var probe = Sys.ActorOf(Akka.Actor.Props.Create(() => new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null)))); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( serviceLevel: publisher, localNode: local, dbHealthProbe: probe, staleWindow: TimeSpan.FromSeconds(30), probeFreshnessWindow: TimeSpan.FromSeconds(30))); actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null)); actor.Tell(new PeerOpcUaProbeActor.OpcUaProbeResult(local, Ok: false)); actor.Tell(new RedundancyStateChanged( Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Secondary, IsClusterLeader: false, IsRoleLeaderForDriver: false, DateTime.UtcNow), }, CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)0), duration: TimeSpan.FromMilliseconds(500)); } /// Verifies branch (3) of OpcUaProbeOk(): a peer's NEGATIVE verdict about this node /// that has aged past _probeFreshnessWindow is given the benefit of the doubt (the peer's /// verdict aged out → don't demote), so the node still publishes the HEALTHY level. With a 1ms /// freshness window and a 30ms wait before the recompute, the cached Ok==false verdict is /// reliably stale by the time the level is computed, so OpcUaProbeOk() returns true and a /// healthy primary-leader computes inputs (true, true, false) → 240, +10 leader → 250 — NOT the 0 /// that a still-fresh negative verdict would produce (cf. ). [Fact] public void Stale_probe_verdict_is_not_demoted_publishes_healthy() { var publisher = new RecordingPublisher(); var local = NodeId.Parse("primary-node"); var probe = Sys.ActorOf(Akka.Actor.Props.Create(() => new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null)))); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( serviceLevel: publisher, localNode: local, dbHealthProbe: probe, staleWindow: TimeSpan.FromSeconds(30), probeFreshnessWindow: TimeSpan.FromMilliseconds(1))); // Seed a healthy DB sample and a NEGATIVE peer verdict about me. (No RedundancyStateChanged yet, // so no level is computed from these — the verdict just gets stamped with the receive time.) actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null)); actor.Tell(new PeerOpcUaProbeActor.OpcUaProbeResult(local, Ok: false)); // Let the verdict age WELL past the 1ms freshness window before any level is computed. The 30ms // wait is >> 1ms, so the verdict is reliably stale by the time OpcUaProbeOk() runs — no reliance // on sub-ms clock ties. ExpectNoMsg(TimeSpan.FromMilliseconds(30)); // Now trigger the recompute with a fresh healthy primary-leader snapshot. By now _probeAboutMe is // >1ms old, so branch (3) ignores the negative verdict and OpcUaProbeOk() returns true. actor.Tell(new RedundancyStateChanged( Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Primary, IsClusterLeader: true, IsRoleLeaderForDriver: true, DateTime.UtcNow), }, CorrelationId.NewId())); // Healthy (250), NOT 0 — proves an aged negative verdict does not demote. AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), duration: TimeSpan.FromMilliseconds(500)); } /// Verifies that with no peer probe result ever received, OpcUaProbeOk() defaults /// to true (benefit of the doubt / single-node). A healthy primary-leader thus computes /// inputs (true, true, false) → 240, +10 leader → 250. [Fact] public void No_probe_result_defaults_ok_true_publishes_250() { var publisher = new RecordingPublisher(); var local = NodeId.Parse("primary-node"); var probe = Sys.ActorOf(Akka.Actor.Props.Create(() => new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null)))); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( serviceLevel: publisher, localNode: local, dbHealthProbe: probe, staleWindow: TimeSpan.FromSeconds(30), probeFreshnessWindow: TimeSpan.FromSeconds(30))); actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null)); actor.Tell(new RedundancyStateChanged( Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Primary, IsClusterLeader: true, IsRoleLeaderForDriver: true, DateTime.UtcNow), }, CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), duration: TimeSpan.FromMilliseconds(500)); } /// Verifies that a later Ok==true peer probe supersedes an earlier Ok==false /// (recovery). A non-leader Secondary entry is used (no +10 bonus): after the true supersedes, /// inputs (true, true, false) → 240, +0 → 240. [Fact] public void Probe_true_supersedes_earlier_false_publishes_240() { var publisher = new RecordingPublisher(); var local = NodeId.Parse("secondary-node"); var probe = Sys.ActorOf(Akka.Actor.Props.Create(() => new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null)))); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( serviceLevel: publisher, localNode: local, dbHealthProbe: probe, staleWindow: TimeSpan.FromSeconds(30), probeFreshnessWindow: TimeSpan.FromSeconds(30))); actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null)); actor.Tell(new PeerOpcUaProbeActor.OpcUaProbeResult(local, Ok: false)); actor.Tell(new PeerOpcUaProbeActor.OpcUaProbeResult(local, Ok: true)); actor.Tell(new RedundancyStateChanged( Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Secondary, IsClusterLeader: false, IsRoleLeaderForDriver: false, DateTime.UtcNow), }, CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), duration: TimeSpan.FromMilliseconds(500)); } /// Verifies that a peer probe result about a DIFFERENT node is ignored — it does not /// affect MY OpcUaProbeOk(), which stays at its default true. A healthy /// primary-leader thus still computes (true, true, false) → 240, +10 → 250. [Fact] public void Probe_about_a_different_node_is_ignored() { var publisher = new RecordingPublisher(); var local = NodeId.Parse("primary-node"); var probe = Sys.ActorOf(Akka.Actor.Props.Create(() => new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null)))); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( serviceLevel: publisher, localNode: local, dbHealthProbe: probe, staleWindow: TimeSpan.FromSeconds(30), probeFreshnessWindow: TimeSpan.FromSeconds(30))); actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null)); actor.Tell(new PeerOpcUaProbeActor.OpcUaProbeResult(NodeId.Parse("someone-else"), Ok: false)); actor.Tell(new RedundancyStateChanged( Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Primary, IsClusterLeader: true, IsRoleLeaderForDriver: true, DateTime.UtcNow), }, CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), duration: TimeSpan.FromMilliseconds(500)); } /// Verifies the legacy back-compat seam: with no DB-health probe wired, the handler /// falls back to the old role-only switch (Primary + leader → 240). [Fact] public void Legacy_path_no_db_probe_keeps_role_only() { var publisher = new RecordingPublisher(); var local = NodeId.Parse("primary-node"); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( serviceLevel: publisher, localNode: local)); actor.Tell(new RedundancyStateChanged( Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Primary, IsClusterLeader: true, IsRoleLeaderForDriver: true, DateTime.UtcNow), }, CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), duration: TimeSpan.FromMilliseconds(500)); } /// Verifies that the periodic HealthTick Asks the local /// for status and pipes the reply back to itself — populating _lastDbHealth in production WITHOUT /// any external pump (no direct Tell). With a healthy DB /// reply and a primary-leader snapshot, the calculator path therefore reaches 250. [Fact] public void HealthTick_asks_db_probe_and_publishes_calculator_byte() { var publisher = new RecordingPublisher(); var local = NodeId.Parse("primary-node"); var db = Sys.ActorOf(Akka.Actor.Props.Create(() => new StubDbHealth( new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null)))); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( serviceLevel: publisher, localNode: local, dbHealthProbe: db, staleWindow: TimeSpan.FromSeconds(30), probeFreshnessWindow: TimeSpan.FromSeconds(30), healthTickInterval: TimeSpan.FromMilliseconds(100))); actor.Tell(new RedundancyStateChanged(new[] { new NodeRedundancyState(local, RedundancyRole.Primary, IsClusterLeader: true, IsRoleLeaderForDriver: true, DateTime.UtcNow), }, CorrelationId.NewId())); // No direct DbHealthStatus Tell — the periodic HealthTick Ask must populate it → 250. AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), TimeSpan.FromSeconds(2)); } /// Stub DB-health probe actor that answers /// with a fixed status, so the calculator path is deterministic without a real timer. private sealed class StubDbHealth : Akka.Actor.ReceiveActor { /// Initializes a new instance of the class. /// The fixed DB-health status to reply with. public StubDbHealth(DbHealthProbeActor.DbHealthStatus status) => Receive(_ => Sender.Tell(status)); } /// Test implementation of IOpcUaAddressSpaceSink that records calls. private sealed class RecordingSink : IOpcUaAddressSpaceSink { /// Gets the queue of recorded value updates. public ConcurrentQueue<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> ValueQueue { get; } = new(); /// Gets the queue of recorded alarm condition updates. public ConcurrentQueue<(string AlarmNodeId, AlarmConditionSnapshot State, DateTime Ts)> AlarmQueue { get; } = new(); /// Count of rebuild calls. public int RebuildCalls; /// Gets the list of recorded value updates. public List<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> Values => ValueQueue.ToList(); /// Gets the list of recorded alarm condition updates. public List<(string AlarmNodeId, AlarmConditionSnapshot State, DateTime Ts)> Alarms => AlarmQueue.ToList(); /// Records a value update. /// The OPC UA node identifier. /// The attribute value. /// The OPC UA quality code. /// The timestamp of the update. public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts) => ValueQueue.Enqueue((nodeId, value, quality, ts)); /// Records an alarm condition update. /// The OPC UA alarm node identifier. /// The full condition state snapshot. /// The timestamp of the update. public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts) => AlarmQueue.Enqueue((alarmNodeId, state, ts)); /// Materialises an alarm condition (no-op in test). /// The alarm node ID. /// The equipment folder node ID. /// The condition display name. /// The domain alarm type. /// The domain severity. public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } /// Ensures a folder exists (no-op in test). /// The OPC UA folder node identifier. /// The parent folder node identifier, or null for root. /// The display name of the folder. public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } /// Ensures a variable exists (no-op in test). /// The OPC UA variable node identifier. /// The parent folder node identifier, or null for root. /// The display name of the variable. /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null) { } /// Records a rebuild call. public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); } /// Test implementation of IServiceLevelPublisher that records publishes. private sealed class RecordingPublisher : IServiceLevelPublisher { private readonly ConcurrentQueue _q = new(); /// Gets the recorded service levels. public byte[] Levels => _q.ToArray(); /// Records a service level publish. /// The service level value to publish. public void Publish(byte serviceLevel) => _q.Enqueue(serviceLevel); } }