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.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", true, false, 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 WriteAlarmState.
[Fact]
public void AlarmStateUpdate_routes_to_sink_WriteAlarmState()
{
var sink = new RecordingSink();
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=A1", Active: true, Acknowledged: false, DateTime.UtcNow));
AwaitAssert(() =>
{
sink.Alarms.Count.ShouldBe(1);
sink.Alarms[0].AlarmNodeId.ShouldBe("ns=2;s=A1");
sink.Alarms[0].Active.ShouldBeTrue();
sink.Alarms[0].Acknowledged.ShouldBeFalse();
}, duration: TimeSpan.FromMilliseconds(500));
}
/// 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 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));
}
/// 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 state updates.
public ConcurrentQueue<(string AlarmNodeId, bool Active, bool Acknowledged, 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 state updates.
public List<(string AlarmNodeId, bool Active, bool Acknowledged, 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 state update.
/// The OPC UA alarm node identifier.
/// Whether the alarm is active.
/// Whether the alarm is acknowledged.
/// The timestamp of the update.
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts) =>
AlarmQueue.Enqueue((alarmNodeId, active, acknowledged, ts));
/// 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.
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
/// 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);
}
}