using Akka.Cluster; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Cluster.Redundancy; namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests; /// /// Unit tests for — the pure ServiceLevel tiering logic. /// Moved from ControlPlane.Tests to Cluster.Tests because the type lives in Core.Cluster. /// See code-reviews/Cluster/findings.md Cluster-005. /// public sealed class ServiceLevelCalculatorTests { /// Non-Up member statuses produce ServiceLevel 0 regardless of other inputs. [Theory] [InlineData(MemberStatus.Down)] [InlineData(MemberStatus.Removed)] [InlineData(MemberStatus.Exiting)] [InlineData(MemberStatus.Leaving)] public void NotUp_returns_zero(MemberStatus status) { var sl = ServiceLevelCalculator.Compute(new(status, DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverPrimary: true)); sl.ShouldBe((byte)0); } /// Up + DB reachable + probe ok + not stale + not leader = tier 240 (healthy follower). [Fact] public void Fully_healthy_non_leader_returns_240() { var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverPrimary: false)); sl.ShouldBe((byte)240); } /// Up + DB reachable + probe ok + not stale + is leader = tier 250 (healthy leader, +10 bonus). [Fact] public void Fully_healthy_role_leader_returns_250() { var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverPrimary: true)); sl.ShouldBe((byte)250); } /// DB reachable but data stale = tier 200 (stale). [Fact] public void Db_reachable_but_stale_returns_200() { var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, DbReachable: true, OpcUaProbeOk: true, Stale: true, IsDriverPrimary: false)); sl.ShouldBe((byte)200); } /// DB unreachable AND stale = tier 100 (critically degraded). [Fact] public void Db_unreachable_and_stale_returns_100() { var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, DbReachable: false, OpcUaProbeOk: false, Stale: true, IsDriverPrimary: false)); sl.ShouldBe((byte)100); } /// OPC UA probe failed while not stale falls through to the catch-all 0. [Fact] public void Opcua_probe_fail_when_not_stale_returns_zero() { var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, DbReachable: true, OpcUaProbeOk: false, Stale: false, IsDriverPrimary: false)); sl.ShouldBe((byte)0); } /// Joining is treated identically to Up for grading (node is joining the cluster). [Fact] public void Joining_member_is_treated_like_Up() { var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Joining, DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverPrimary: false)); sl.ShouldBe((byte)240); } /// Leader bonus is clamped to 255 even if a hypothetical basis would overflow. [Fact] public void Result_is_clamped_to_255() { // basis 240 + 10 = 250, already within byte range; confirms Clamp is in path var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverPrimary: true)); ((int)sl).ShouldBeLessThanOrEqualTo(255); } /// /// lmxopcua#762: a node whose own OPC UA endpoint failed to start computes 0 — whatever every other input /// says, and in particular without the +10 Primary bonus that used to keep a dead endpoint at 250. /// [Theory] [InlineData(true, true, false, true)] // would be 250 [InlineData(true, true, false, false)] // would be 240 [InlineData(true, true, true, true)] // would be 210 [InlineData(false, true, true, false)] // would be 100 public void Endpoint_not_listening_returns_zero(bool dbReachable, bool probeOk, bool stale, bool primary) { var healthy = ServiceLevelCalculator.Compute(new(MemberStatus.Up, DbReachable: dbReachable, OpcUaProbeOk: probeOk, Stale: stale, IsDriverPrimary: primary)); healthy.ShouldBeGreaterThan((byte)0, "positive control: the same inputs with a listening endpoint must not already be 0"); var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, DbReachable: dbReachable, OpcUaProbeOk: probeOk, Stale: stale, IsDriverPrimary: primary, EndpointListening: false)); sl.ShouldBe((byte)0); } /// The endpoint input defaults to listening, so callers with no endpoint fact keep the old tiering. [Fact] public void Endpoint_listening_defaults_to_true() { new NodeHealthInputs(MemberStatus.Up, true, true, false, true).EndpointListening.ShouldBeTrue(); } }