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();
}
}
///
/// Gitea #537 — the continuous-historization capture gate.
///
///
///
/// Same role rule as the alarm drain, and for the same reason: the historian is append-only, so
/// a false allow costs a duplicate sample while a false deny costs a permanent hole in the
/// trend. What differs is the peer question — the alarm queue is pair-local and
/// replicated, so the peer that matters is whoever holds a copy of these rows; historization is
/// fleet-wide by tag name, so the peer that matters is whoever records these same refs.
///
///
/// It also gates the opposite half of the pipeline. The alarm sink gates its DRAIN, because the
/// Secondary already holds a replica of the Primary's queue. The historization outbox is
/// node-local and replicates to nobody, so gating only the drain would leave the standby
/// appending forever to a queue no one empties. See
/// PrimaryGatePolicy.ShouldRecordHistorizedValues.
///
///
public sealed class HistorizationCaptureGatePolicyTests
{
[Theory]
[InlineData(RedundancyRole.Primary, false, true)]
[InlineData(RedundancyRole.Primary, true, true)] // Primary captures even if another claims it
[InlineData(RedundancyRole.Secondary, true, false)] // its mesh partner is recording these refs
[InlineData(RedundancyRole.Detached, true, false)]
public void Known_role_decides_against_the_peer(
RedundancyRole role, bool peerIsPrimary, bool expected)
=> PrimaryGatePolicy.ShouldRecordHistorizedValues(role, peerIsPrimary).ShouldBe(expected);
///
/// A Secondary whose Primary does not record these refs keeps capturing.
///
///
/// This is the case that makes the peer argument load-bearing rather than decorative. Under a
/// fleet-wide election the elected Primary is usually running some other cluster's
/// configuration entirely, and it will never record this node's tags. A bare "somebody is
/// Primary ⇒ stand down" rule ends this cluster's historization outright, and reports nothing
/// wrong: the drain simply succeeds on an empty outbox forever. It is the same scope mismatch
/// that once suspended the alarm drain on every node of the docker-dev rig at once.
///
[Fact]
public void A_secondary_whose_primary_records_other_tags_keeps_capturing()
=> PrimaryGatePolicy
.ShouldRecordHistorizedValues(RedundancyRole.Secondary, addressSpaceSharingPeerIsPrimary: false)
.ShouldBeTrue("a Secondary must not defer to a Primary that records none of its tags");
///
/// Unknown role captures — a deployment that never publishes a role must still historize.
///
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Unknown_role_captures_whatever_the_peer_says(bool peerIsPrimary)
=> PrimaryGatePolicy.ShouldRecordHistorizedValues(null, peerIsPrimary).ShouldBeTrue();
///
/// Pinned against a future "harmonisation": with no role known and a peer present, the
/// device-write gate denies while this one allows.
///
[Fact]
public void It_diverges_from_the_write_gate_when_the_role_is_unknown()
{
PrimaryGatePolicy.ShouldServiceAsPrimary(localRole: null, driverMemberCount: 4).ShouldBeFalse();
PrimaryGatePolicy
.ShouldRecordHistorizedValues(localRole: null, addressSpaceSharingPeerIsPrimary: true)
.ShouldBeTrue();
}
}