using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
///
/// The bridge that carries the Primary-gate decision out of the actor and into
/// , where the alarm store-and-forward drain can read it.
///
///
///
/// The drain runs on a timer owned by the sink, not on a mailbox, so it cannot receive
/// itself — but it must be Primary-scoped, because
/// the queue it drains replicates to the pair peer and two draining nodes would deliver
/// every alarm event twice.
///
///
/// These cases mirror 's matrix on purpose:
/// the view must reach exactly the same verdict as the inbound-write and native-ack gates,
/// because a second, subtly different notion of "am I the Primary" is how a pair ends up
/// with one node writing and neither draining.
///
///
public sealed class DriverHostActorRoleViewTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("driver-roleview-test");
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
///
/// Before any snapshot arrives the view must read open, not closed.
///
///
/// A deployment that runs no redundancy never publishes here at all. Defaulting closed
/// would silently stop its alarm history forever — the exact failure the drain gate exists
/// to avoid causing.
///
[Fact]
public void An_unpublished_view_reads_as_primary()
{
new RedundancyRoleView().ShouldServiceAsPrimary.ShouldBeTrue();
}
[Fact]
public void Secondary_role_closes_the_view()
{
var view = SpawnAndTellRole(RedundancyRole.Secondary, driverMemberCount: 2);
AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeFalse(), duration: Timeout);
}
[Fact]
public void Primary_role_opens_the_view()
{
var view = SpawnAndTellRole(RedundancyRole.Primary, driverMemberCount: 2);
AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeTrue(), duration: Timeout);
}
/// Unknown role + a real driver peer ⇒ closed, matching the write gate's default-DENY.
[Fact]
public void Unknown_role_with_a_driver_peer_closes_the_view()
{
// A snapshot that never names this node is how the role stays unknown in practice — the
// documented identity-mismatch shape, not a contrived case.
var view = SpawnAndTellForeignSnapshot(driverMemberCount: 2);
AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeFalse(), duration: Timeout);
}
/// Unknown role + no driver peer ⇒ open, preserving the single-node posture.
[Fact]
public void Unknown_role_without_a_driver_peer_leaves_the_view_open()
{
var view = SpawnAndTellForeignSnapshot(driverMemberCount: 1);
AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeTrue(), duration: Timeout);
}
/// A demotion must move the view, not just an initial promotion.
[Fact]
public void A_demotion_closes_a_previously_open_view()
{
var view = new RedundancyRoleView();
var host = SpawnHost(view, driverMemberCount: 2);
host.Tell(Snapshot(TestNode, RedundancyRole.Primary));
AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeTrue(), duration: Timeout);
host.Tell(Snapshot(TestNode, RedundancyRole.Secondary));
AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeFalse(), duration: Timeout);
}
// ---------------- helpers ----------------
private RedundancyRoleView SpawnAndTellRole(RedundancyRole role, int driverMemberCount)
{
var view = new RedundancyRoleView();
SpawnHost(view, driverMemberCount).Tell(Snapshot(TestNode, role));
return view;
}
private RedundancyRoleView SpawnAndTellForeignSnapshot(int driverMemberCount)
{
var view = new RedundancyRoleView();
SpawnHost(view, driverMemberCount).Tell(Snapshot(NodeId.Parse("some-other-node"), RedundancyRole.Primary));
return view;
}
private IActorRef SpawnHost(IRedundancyRoleView view, int driverMemberCount) =>
Sys.ActorOf(DriverHostActor.Props(
NewInMemoryDbFactory(),
TestNode,
coordinator: null,
driverMemberCountProvider: () => driverMemberCount,
redundancyRoleView: view));
private static RedundancyStateChanged Snapshot(NodeId node, RedundancyRole role) =>
new(
[
new NodeRedundancyState(node, role,
IsClusterLeader: role == RedundancyRole.Primary,
IsRoleLeaderForDriver: role == RedundancyRole.Primary,
AsOfUtc: DateTime.UtcNow),
],
CorrelationId.NewId());
}