using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Server.Redundancy; namespace ZB.MOM.WW.OtOpcUa.Server.Tests; [Trait("Category", "Unit")] public sealed class ServiceLevelCalculatorTests { // --- Reserved bands (0, 1, 2) --- [Fact] public void OperatorMaintenance_Overrides_Everything() { var v = ServiceLevelCalculator.Compute( RedundancyRole.Primary, selfHealthy: true, peerUaHealthy: true, peerHttpHealthy: true, applyInProgress: false, recoveryDwellMet: true, topologyValid: true, operatorMaintenance: true); v.ShouldBe((byte)ServiceLevelBand.Maintenance); } [Fact] public void UnhealthySelf_ReturnsNoData() { var v = ServiceLevelCalculator.Compute( RedundancyRole.Primary, selfHealthy: false, peerUaHealthy: true, peerHttpHealthy: true, applyInProgress: false, recoveryDwellMet: true, topologyValid: true); v.ShouldBe((byte)ServiceLevelBand.NoData); } [Fact] public void InvalidTopology_Demotes_BothNodes_To_2() { var primary = ServiceLevelCalculator.Compute( RedundancyRole.Primary, selfHealthy: true, peerUaHealthy: true, peerHttpHealthy: true, applyInProgress: false, recoveryDwellMet: true, topologyValid: false); var secondary = ServiceLevelCalculator.Compute( RedundancyRole.Secondary, selfHealthy: true, peerUaHealthy: true, peerHttpHealthy: true, applyInProgress: false, recoveryDwellMet: true, topologyValid: false); primary.ShouldBe((byte)ServiceLevelBand.InvalidTopology); secondary.ShouldBe((byte)ServiceLevelBand.InvalidTopology); } // --- Operational bands (authoritative) --- [Fact] public void Authoritative_Primary_Is_255() { var v = ServiceLevelCalculator.Compute( RedundancyRole.Primary, selfHealthy: true, peerUaHealthy: true, peerHttpHealthy: true, applyInProgress: false, recoveryDwellMet: true, topologyValid: true); v.ShouldBe((byte)ServiceLevelBand.AuthoritativePrimary); v.ShouldBe((byte)255); } [Fact] public void Authoritative_Backup_Is_100() { var v = ServiceLevelCalculator.Compute( RedundancyRole.Secondary, selfHealthy: true, peerUaHealthy: true, peerHttpHealthy: true, applyInProgress: false, recoveryDwellMet: true, topologyValid: true); v.ShouldBe((byte)100); } // --- Isolated bands --- [Fact] public void IsolatedPrimary_PeerUnreachable_Is_230_RetainsAuthority() { var v = ServiceLevelCalculator.Compute( RedundancyRole.Primary, selfHealthy: true, peerUaHealthy: false, peerHttpHealthy: true, applyInProgress: false, recoveryDwellMet: true, topologyValid: true); v.ShouldBe((byte)230); } [Fact] public void IsolatedBackup_PrimaryUnreachable_Is_80_DoesNotPromote() { var v = ServiceLevelCalculator.Compute( RedundancyRole.Secondary, selfHealthy: true, peerUaHealthy: false, peerHttpHealthy: false, applyInProgress: false, recoveryDwellMet: true, topologyValid: true); v.ShouldBe((byte)80, "Backup isolates at 80 — doesn't auto-promote to 255"); } [Fact] public void HttpOnly_Unreachable_TriggersIsolated() { // Either probe failing marks peer unreachable — UA probe is authoritative but HTTP is // the fast-fail short-circuit; either missing means "not a valid peer right now". var v = ServiceLevelCalculator.Compute( RedundancyRole.Primary, selfHealthy: true, peerUaHealthy: true, peerHttpHealthy: false, applyInProgress: false, recoveryDwellMet: true, topologyValid: true); v.ShouldBe((byte)230); } // --- Apply-mid bands --- [Fact] public void PrimaryMidApply_Is_200() { var v = ServiceLevelCalculator.Compute( RedundancyRole.Primary, selfHealthy: true, peerUaHealthy: true, peerHttpHealthy: true, applyInProgress: true, recoveryDwellMet: true, topologyValid: true); v.ShouldBe((byte)200); } [Fact] public void BackupMidApply_Is_50() { var v = ServiceLevelCalculator.Compute( RedundancyRole.Secondary, selfHealthy: true, peerUaHealthy: true, peerHttpHealthy: true, applyInProgress: true, recoveryDwellMet: true, topologyValid: true); v.ShouldBe((byte)50); } [Fact] public void ApplyInProgress_Dominates_PeerUnreachable() { // Per Stream C.4 integration-test expectation: mid-apply + peer down → apply wins (200). var v = ServiceLevelCalculator.Compute( RedundancyRole.Primary, selfHealthy: true, peerUaHealthy: false, peerHttpHealthy: false, applyInProgress: true, recoveryDwellMet: true, topologyValid: true); v.ShouldBe((byte)200); } // --- Recovering bands --- [Fact] public void RecoveringPrimary_Is_180() { var v = ServiceLevelCalculator.Compute( RedundancyRole.Primary, selfHealthy: true, peerUaHealthy: true, peerHttpHealthy: true, applyInProgress: false, recoveryDwellMet: false, topologyValid: true); v.ShouldBe((byte)180); } [Fact] public void RecoveringBackup_Is_30() { var v = ServiceLevelCalculator.Compute( RedundancyRole.Secondary, selfHealthy: true, peerUaHealthy: true, peerHttpHealthy: true, applyInProgress: false, recoveryDwellMet: false, topologyValid: true); v.ShouldBe((byte)30); } // --- Standalone node (no peer) --- [Fact] public void Standalone_IsAuthoritativePrimary_WhenHealthy() { var v = ServiceLevelCalculator.Compute( RedundancyRole.Standalone, selfHealthy: true, peerUaHealthy: false, peerHttpHealthy: false, applyInProgress: false, recoveryDwellMet: true, topologyValid: true); v.ShouldBe((byte)255, "Standalone has no peer — treat healthy as authoritative"); } [Fact] public void Standalone_MidApply_Is_200() { var v = ServiceLevelCalculator.Compute( RedundancyRole.Standalone, selfHealthy: true, peerUaHealthy: false, peerHttpHealthy: false, applyInProgress: true, recoveryDwellMet: true, topologyValid: true); v.ShouldBe((byte)200); } // --- Classify round-trip --- [Theory] [InlineData((byte)0, ServiceLevelBand.Maintenance)] [InlineData((byte)1, ServiceLevelBand.NoData)] [InlineData((byte)2, ServiceLevelBand.InvalidTopology)] [InlineData((byte)30, ServiceLevelBand.RecoveringBackup)] [InlineData((byte)50, ServiceLevelBand.BackupMidApply)] [InlineData((byte)80, ServiceLevelBand.IsolatedBackup)] [InlineData((byte)100, ServiceLevelBand.AuthoritativeBackup)] [InlineData((byte)180, ServiceLevelBand.RecoveringPrimary)] [InlineData((byte)200, ServiceLevelBand.PrimaryMidApply)] [InlineData((byte)230, ServiceLevelBand.IsolatedPrimary)] [InlineData((byte)255, ServiceLevelBand.AuthoritativePrimary)] [InlineData((byte)123, ServiceLevelBand.Unknown)] public void Classify_RoundTrips_EveryBand(byte value, ServiceLevelBand expected) { ServiceLevelCalculator.Classify(value).ShouldBe(expected); } }