Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Communication/NodeCommunicationActorTests.cs
T
Joseph Doherty ab5b958d47 refactor(mesh): step 2 — delete the node-side ClusterClient and its centralClient parameter
WithOtOpcUaRuntimeActors no longer builds a ClusterClient from
MeshTransport:CentralContactPoints, and NodeCommunicationActor.Props takes only the gRPC
IApplyAckSender. The ackRouter gate narrows from `useClusterClient || useGrpc` to `useGrpc`,
and the CentralClusterClientName constant is gone.

NodeCommunicationActor's no-transport warning now names MeshTransport:CentralGrpcEndpoints
and MeshTransport:Mode only — the old text pointed an operator at a key that no longer
exists.

Ripples in this step: the two ClusterClient-shaped facts in NodeCommunicationActorTests
(Send-not-SendToAll, and sender-wins-over-a-client) are deleted — the first has no subject
left, the second guarded a both-transports-present state that is now unconstructible. The
Runtime ServiceCollectionExtensions fact keeps its ackRouter pin and drops its
"no ClusterClient actor exists" half for the same reason.
2026-08-06 15:38:49 -04:00

178 lines
6.8 KiB
C#

using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Runtime.Communication;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Communication;
public class NodeCommunicationActorTests : RuntimeActorTestBase
{
private static DispatchDeployment NewDispatch() => new(
new DeploymentId(Guid.NewGuid()),
new RevisionHash("rev-1"),
new CorrelationId(Guid.NewGuid()));
[Fact]
public void DispatchDeployment_from_central_lands_on_the_node_local_event_stream()
{
var subscriber = CreateTestProbe();
Sys.EventStream.Subscribe(subscriber.Ref, typeof(DispatchDeployment));
var actor = Sys.ActorOf(NodeCommunicationActor.Props());
var dispatch = NewDispatch();
actor.Tell(dispatch);
subscriber.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(5)).ShouldBe(dispatch);
}
[Fact]
public void A_command_is_published_exactly_once()
{
// The reason inbound commands do NOT go back onto their DPS topic: central SendToAll-s to
// every node's comm actor, so a mesh-wide republish would deliver N copies to every
// subscriber. One publish per inbound command is the whole point.
var subscriber = CreateTestProbe();
Sys.EventStream.Subscribe(subscriber.Ref, typeof(DispatchDeployment));
var actor = Sys.ActorOf(NodeCommunicationActor.Props());
actor.Tell(NewDispatch());
subscriber.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(5));
subscriber.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
[Fact]
public void RestartDriver_from_central_lands_on_the_event_stream()
{
var subscriber = CreateTestProbe();
Sys.EventStream.Subscribe(subscriber.Ref, typeof(RestartDriver));
var actor = Sys.ActorOf(NodeCommunicationActor.Props());
var msg = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid());
actor.Tell(msg);
subscriber.ExpectMsg<RestartDriver>(TimeSpan.FromSeconds(5)).ShouldBe(msg);
}
[Fact]
public void ReconnectDriver_from_central_lands_on_the_event_stream()
{
var subscriber = CreateTestProbe();
Sys.EventStream.Subscribe(subscriber.Ref, typeof(ReconnectDriver));
var actor = Sys.ActorOf(NodeCommunicationActor.Props());
var msg = new ReconnectDriver("C1", "driver-1", "alice", Guid.NewGuid());
actor.Tell(msg);
subscriber.ExpectMsg<ReconnectDriver>(TimeSpan.FromSeconds(5)).ShouldBe(msg);
}
[Fact]
public void AlarmCommand_from_central_lands_on_the_event_stream()
{
// ScriptedAlarmHostActor is a CHILD of DriverHostActor with no registry key, so there is no
// ref to hand this actor at wiring time — the EventStream is what makes the alarm-command
// leg possible without a registration handshake.
var subscriber = CreateTestProbe();
Sys.EventStream.Subscribe(subscriber.Ref, typeof(AlarmCommand));
var actor = Sys.ActorOf(NodeCommunicationActor.Props());
var msg = new AlarmCommand("alarm-1", "Acknowledge", "alice", "comment", null);
actor.Tell(msg);
subscriber.ExpectMsg<AlarmCommand>(TimeSpan.FromSeconds(5)).ShouldBe(msg);
}
[Fact]
public void An_ack_with_no_ack_sender_is_dropped_with_a_warning()
{
// Dps: DriverHostActor publishes the ack on the deployment-acks topic and never routes it
// here, so an ack arriving with no sender wired is a mis-wiring and must say so.
var actor = Sys.ActorOf(NodeCommunicationActor.Props());
var ack = new ApplyAck(
new DeploymentId(Guid.NewGuid()),
new NodeId("host-a:4053"),
ApplyAckOutcome.Applied,
null,
new CorrelationId(Guid.NewGuid()));
EventFilter.Warning(contains: "dropping ApplyAck").ExpectOne(
TimeSpan.FromSeconds(5), () => actor.Tell(ack));
}
// ---- Mode=Grpc (the cross-mesh ack leg) -----------------------------------------------------
private static ApplyAck NewAck() => new(
new DeploymentId(Guid.NewGuid()),
new NodeId("host-a:4053"),
ApplyAckOutcome.Applied,
null,
new CorrelationId(Guid.NewGuid()));
[Fact]
public void Under_Grpc_the_ack_goes_to_the_sender_exactly_once()
{
// One post per node per dispatch, because both central endpoints route to the SAME
// coordinator singleton proxy. Where the ack goes among the endpoints is the sender's
// business (GrpcApplyAckSenderTests); what this pins is that the actor hands it over once
// and does not also fan it out itself.
var sender = new RecordingAckSender();
var actor = Sys.ActorOf(NodeCommunicationActor.Props(sender));
var ack = NewAck();
actor.Tell(ack);
AwaitAssert(() => sender.Sent.Count.ShouldBe(1), TimeSpan.FromSeconds(5));
sender.Sent[0].ShouldBe(ack);
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
sender.Sent.Count.ShouldBe(1);
}
[Fact]
public void Inbound_commands_are_unaffected_by_the_presence_of_a_sender()
{
// The sender is outbound-only. A Grpc node still re-emits central's commands on its own
// EventStream exactly as every other mode does.
var subscriber = CreateTestProbe();
Sys.EventStream.Subscribe(subscriber.Ref, typeof(DispatchDeployment));
var sender = new RecordingAckSender();
var actor = Sys.ActorOf(NodeCommunicationActor.Props(sender));
var dispatch = NewDispatch();
actor.Tell(dispatch);
subscriber.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(5)).ShouldBe(dispatch);
sender.Sent.ShouldBeEmpty();
}
/// <summary>An <see cref="IApplyAckSender"/> double recording what the actor handed it.</summary>
private sealed class RecordingAckSender : IApplyAckSender
{
private readonly Lock _gate = new();
private readonly List<ApplyAck> _sent = [];
/// <summary>Gets a snapshot of the acks handed to this sender.</summary>
public IReadOnlyList<ApplyAck> Sent
{
get { lock (_gate) return [.. _sent]; }
}
/// <summary>Records <paramref name="ack"/>.</summary>
/// <param name="ack">The ack the actor handed over.</param>
/// <param name="cancellationToken">Unused.</param>
/// <returns>A completed task — the real sender never throws either.</returns>
public Task SendAsync(ApplyAck ack, CancellationToken cancellationToken = default)
{
lock (_gate) _sent.Add(ack);
return Task.CompletedTask;
}
}
}