using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
///
/// archreview 03/S4 — the single Primary data-plane gate decision. A KNOWN role wins outright; an
/// UNKNOWN role (no snapshot yet, or the snapshot never mentioned this node) is resolved by cluster
/// membership: a single-driver cluster stays default-ALLOW (boot-window / single-node posture), a
/// multi-driver cluster is default-DENY (a real Primary peer exists — don't touch the shared field
/// device until the snapshot proves this node is Primary).
///
public sealed class PrimaryGatePolicyTests
{
[Theory]
// Primary → always service, regardless of member count.
[InlineData(RedundancyRole.Primary, 0, true)]
[InlineData(RedundancyRole.Primary, 1, true)]
[InlineData(RedundancyRole.Primary, 2, true)]
// Secondary / Detached → never service, regardless of member count.
[InlineData(RedundancyRole.Secondary, 0, false)]
[InlineData(RedundancyRole.Secondary, 2, false)]
[InlineData(RedundancyRole.Detached, 0, false)]
[InlineData(RedundancyRole.Detached, 2, false)]
public void Known_role_wins_regardless_of_member_count(RedundancyRole role, int members, bool expected)
=> PrimaryGatePolicy.ShouldServiceAsPrimary(role, members).ShouldBe(expected);
[Theory]
// Unknown role: allow only when no driver peer exists (count <= 1).
[InlineData(0, true)]
[InlineData(1, true)]
[InlineData(2, false)]
[InlineData(3, false)]
public void Unknown_role_resolves_by_membership(int members, bool expected)
=> PrimaryGatePolicy.ShouldServiceAsPrimary(null, members).ShouldBe(expected);
}
///
/// The alarm-history drain gate, which deliberately does not mirror the device-write gate.
///
///
///
/// Why a second policy is correct here. The write gate protects a shared field device:
/// two nodes driving one PLC is dangerous and irreversible, so an unknown role with a peer
/// present must deny. The drain writes to an append-only historian instead, and Phase 2's
/// delivery contract is already at-least-once across a failover, by design — identical
/// events even collapse to one row, because ids are a hash of the payload.
///
///
/// So the two error costs are wildly asymmetric: a false allow costs a duplicate history row,
/// while a false deny silently stops the alarm audit trail and eventually evicts it at the
/// capacity wall. This gate therefore keys on the role alone and opens when the role is
/// unknown.
///
///
/// Found by the LocalDb Phase 2 live gate. The rig runs four driver nodes in one Akka
/// cluster with no Redundancy section, so every node's role was unknown and the
/// cluster-wide driver count was 4 — and every node, including the two unpaired site-b nodes
/// that have no peer and no replication at all, logged "Historian drain suspended". Nothing
/// drained anywhere. Before Phase 2 that deployment drained fine, because the drain was ungated.
///
///
public sealed class AlarmHistoryDrainGatePolicyTests
{
[Theory]
// A Primary always drains its own queue.
[InlineData(RedundancyRole.Primary, true, true)]
[InlineData(RedundancyRole.Primary, false, true)]
// A Secondary stands down ONLY when a Primary actually exists to stand up.
[InlineData(RedundancyRole.Secondary, true, false)]
[InlineData(RedundancyRole.Detached, true, false)]
[InlineData(RedundancyRole.Secondary, false, true)]
[InlineData(RedundancyRole.Detached, false, true)]
public void A_known_role_stands_down_only_for_a_primary_that_exists(
RedundancyRole role, bool queueSharingPeerIsPrimary, bool expected)
=> PrimaryGatePolicy.ShouldDrainAlarmHistory(role, queueSharingPeerIsPrimary).ShouldBe(expected);
///
/// Unknown role ⇒ drain, whoever else is out there. The first case the live gate broke on, and
/// the one that separates this policy from .
///
[Theory]
[InlineData(true)]
[InlineData(false)]
public void An_unknown_role_drains(bool queueSharingPeerIsPrimary)
=> PrimaryGatePolicy.ShouldDrainAlarmHistory(null, queueSharingPeerIsPrimary).ShouldBeTrue();
///
/// A Secondary whose Primary is NOT its queue-sharing peer keeps draining — the deepest of the
/// live gate's findings.
///
///
/// RedundancyStateActor derives roles from Akka's cluster-wide RoleLeader("driver"),
/// but the alarm queue is pair-local. On the docker-dev rig the elected Primary is a CENTRAL node
/// — it carries the driver Akka role, replicates nobody's LocalDb, and does not even run the
/// alarm historian — so under a plain "a Primary exists ⇒ stand down" rule every site node
/// suspended its drain in favour of a node that could not possibly deliver its events. The
/// buffer then grows on every node until the capacity wall evicts the oldest: silent, permanent
/// loss of the audit trail the queue exists to protect.
///
[Fact]
public void A_secondary_whose_peer_is_not_the_primary_keeps_draining()
{
// A Primary exists somewhere in the cluster, but it does not hold this node's rows.
PrimaryGatePolicy.ShouldDrainAlarmHistory(RedundancyRole.Secondary, queueSharingPeerIsPrimary: false)
.ShouldBeTrue("a Secondary must not defer to a Primary that cannot deliver its events");
}
///
/// The divergence, pinned explicitly: with a peer present and no role known, the write gate
/// denies and the drain gate allows. If someone later "harmonises" the two, this fails.
///
[Fact]
public void The_two_gates_deliberately_disagree_when_the_role_is_unknown_and_a_peer_exists()
{
PrimaryGatePolicy.ShouldServiceAsPrimary(localRole: null, driverMemberCount: 4).ShouldBeFalse();
PrimaryGatePolicy.ShouldDrainAlarmHistory(localRole: null, queueSharingPeerIsPrimary: true).ShouldBeTrue();
}
}