using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Resilience; namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience; [Trait("Category", "Unit")] public sealed class DriverResilienceOptionsParserTests { /// Verifies that null JSON returns pure tier defaults. [Fact] public void NullJson_ReturnsPureTierDefaults() { var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, null, out var diag); diag.ShouldBeNull(); options.Tier.ShouldBe(DriverTier.A); options.Resolve(DriverCapability.Read).ShouldBe( DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]); } /// Verifies that whitespace JSON returns defaults. [Fact] public void WhitespaceJson_ReturnsDefaults() { DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.B, " ", out var diag); diag.ShouldBeNull(); } /// Verifies that malformed JSON falls back with diagnostic. [Fact] public void MalformedJson_FallsBack_WithDiagnostic() { var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, "{not json", out var diag); diag.ShouldNotBeNull(); diag.ShouldContain("malformed"); options.Tier.ShouldBe(DriverTier.A); options.Resolve(DriverCapability.Read).ShouldBe( DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]); } /// Verifies that empty object returns defaults. [Fact] public void EmptyObject_ReturnsDefaults() { var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, "{}", out var diag); diag.ShouldBeNull(); options.Resolve(DriverCapability.Write).ShouldBe( DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]); } /// Verifies that Read override is merged into tier defaults. [Fact] public void ReadOverride_MergedIntoTierDefaults() { var json = """ { "capabilityPolicies": { "Read": { "timeoutSeconds": 5, "retryCount": 7, "breakerFailureThreshold": 2 } } } """; var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag); diag.ShouldBeNull(); var read = options.Resolve(DriverCapability.Read); read.TimeoutSeconds.ShouldBe(5); read.RetryCount.ShouldBe(7); read.BreakerFailureThreshold.ShouldBe(2); // Other capabilities untouched options.Resolve(DriverCapability.Write).ShouldBe( DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]); } /// Verifies that partial policy fills missing fields from tier default. [Fact] public void PartialPolicy_FillsMissingFieldsFromTierDefault() { var json = """ { "capabilityPolicies": { "Read": { "retryCount": 10 } } } """; var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out _); var read = options.Resolve(DriverCapability.Read); var tierDefault = DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]; read.RetryCount.ShouldBe(10); read.TimeoutSeconds.ShouldBe(tierDefault.TimeoutSeconds, "partial override; timeout falls back to tier default"); read.BreakerFailureThreshold.ShouldBe(tierDefault.BreakerFailureThreshold); } /// Verifies that bulkhead overrides are honored. [Fact] public void BulkheadOverrides_AreHonored() { var json = """ { "bulkheadMaxConcurrent": 100, "bulkheadMaxQueue": 500 } """; var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.B, json, out _); options.BulkheadMaxConcurrent.ShouldBe(100); options.BulkheadMaxQueue.ShouldBe(500); } /// Verifies that unknown capability surfaces in diagnostic but does not fail. [Fact] public void UnknownCapability_Surfaces_InDiagnostic_ButDoesNotFail() { var json = """ { "capabilityPolicies": { "InventedCapability": { "timeoutSeconds": 99 } } } """; var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag); diag.ShouldNotBeNull(); diag.ShouldContain("InventedCapability"); // Known capabilities untouched. options.Resolve(DriverCapability.Read).ShouldBe( DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]); } /// Verifies that property names are case insensitive. [Fact] public void PropertyNames_AreCaseInsensitive() { var json = """ { "BULKHEADMAXCONCURRENT": 42 } """; var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out _); options.BulkheadMaxConcurrent.ShouldBe(42); } /// Verifies that capability name is case insensitive. [Fact] public void CapabilityName_IsCaseInsensitive() { var json = """ { "capabilityPolicies": { "read": { "retryCount": 99 } } } """; var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag); diag.ShouldBeNull(); options.Resolve(DriverCapability.Read).RetryCount.ShouldBe(99); } /// Verifies that every tier with empty JSON round-trips its defaults. /// The driver tier to test. [Theory] [InlineData(DriverTier.A)] [InlineData(DriverTier.B)] [InlineData(DriverTier.C)] public void EveryTier_WithEmptyJson_RoundTrips_Its_Defaults(DriverTier tier) { var options = DriverResilienceOptionsParser.ParseOrDefaults(tier, "{}", out var diag); diag.ShouldBeNull(); options.Tier.ShouldBe(tier); foreach (var cap in Enum.GetValues()) options.Resolve(cap).ShouldBe(DriverResilienceOptions.GetTierDefaults(tier)[cap]); } /// Verifies that RecycleIntervalSeconds on Tier C with positive value parses and surfaces. [Fact] public void RecycleIntervalSeconds_TierC_PositiveValue_ParsesAndSurfaces() { var options = DriverResilienceOptionsParser.ParseOrDefaults( DriverTier.C, "{\"recycleIntervalSeconds\":3600}", out var diag); diag.ShouldBeNull(); options.RecycleIntervalSeconds.ShouldBe(3600); } /// Verifies that RecycleIntervalSeconds when null defaults to null. [Fact] public void RecycleIntervalSeconds_Null_DefaultsToNull() { var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.C, "{}", out _); options.RecycleIntervalSeconds.ShouldBeNull(); } /// Verifies that RecycleIntervalSeconds on Tier A or B is rejected with diagnostic. /// The driver tier to test. [Theory] [InlineData(DriverTier.A)] [InlineData(DriverTier.B)] public void RecycleIntervalSeconds_OnTierAorB_Rejected_With_Diagnostic(DriverTier tier) { // Decision #74 — in-process drivers must not scheduled-recycle because it would // tear down every OPC UA session. The parser surfaces a diagnostic rather than // silently honouring the value. var options = DriverResilienceOptionsParser.ParseOrDefaults( tier, "{\"recycleIntervalSeconds\":3600}", out var diag); options.RecycleIntervalSeconds.ShouldBeNull(); diag.ShouldContain("Tier C only"); } /// Verifies that RecycleIntervalSeconds with non-positive value is rejected with diagnostic. [Fact] public void RecycleIntervalSeconds_NonPositive_Rejected_With_Diagnostic() { var options = DriverResilienceOptionsParser.ParseOrDefaults( DriverTier.C, "{\"recycleIntervalSeconds\":0}", out var diag); options.RecycleIntervalSeconds.ShouldBeNull(); diag.ShouldContain("must be positive"); } // ---- 01/S-6: range clamps (clamp + diagnostic, never brick) ---- /// A non-positive timeout is nonsense; it snaps to the capability's tier default with a diagnostic. [Fact] public void TimeoutSeconds_Zero_FallsBackToTierDefault_WithDiagnostic() { var options = DriverResilienceOptionsParser.ParseOrDefaults( DriverTier.A, "{\"capabilityPolicies\":{\"Read\":{\"timeoutSeconds\":0}}}", out var diag); var tierDefault = DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]; options.Resolve(DriverCapability.Read).TimeoutSeconds.ShouldBe(tierDefault.TimeoutSeconds); diag.ShouldNotBeNull(); diag.ShouldContain("timeoutSeconds"); } /// A timeout above Polly's ceiling clamps to the max with a diagnostic. [Fact] public void TimeoutSeconds_AbovePollyMax_Clamped() { var options = DriverResilienceOptionsParser.ParseOrDefaults( DriverTier.A, "{\"capabilityPolicies\":{\"Read\":{\"timeoutSeconds\":999999}}}", out var diag); options.Resolve(DriverCapability.Read).TimeoutSeconds.ShouldBe(ResiliencePolicyRanges.MaxTimeoutSeconds); diag.ShouldNotBeNull(); diag.ShouldContain("timeoutSeconds"); } /// A negative retry count clamps to zero with a diagnostic. [Fact] public void RetryCount_Negative_ClampedToZero() { var options = DriverResilienceOptionsParser.ParseOrDefaults( DriverTier.A, "{\"capabilityPolicies\":{\"Read\":{\"retryCount\":-1}}}", out var diag); options.Resolve(DriverCapability.Read).RetryCount.ShouldBe(0); diag.ShouldNotBeNull(); diag.ShouldContain("retryCount"); } /// An absurd retry count clamps to the operational cap with a diagnostic. [Fact] public void RetryCount_AboveCap_ClampedTo100() { var options = DriverResilienceOptionsParser.ParseOrDefaults( DriverTier.A, "{\"capabilityPolicies\":{\"Read\":{\"retryCount\":2000000000}}}", out var diag); options.Resolve(DriverCapability.Read).RetryCount.ShouldBe(ResiliencePolicyRanges.MaxRetryCount); diag.ShouldNotBeNull(); diag.ShouldContain("retryCount"); } /// A breaker threshold of 1 (below Polly's MinimumThroughput floor) raises to 2 with a diagnostic. [Fact] public void BreakerThreshold_One_ClampedToTwo() { var options = DriverResilienceOptionsParser.ParseOrDefaults( DriverTier.A, "{\"capabilityPolicies\":{\"Read\":{\"breakerFailureThreshold\":1}}}", out var diag); options.Resolve(DriverCapability.Read).BreakerFailureThreshold.ShouldBe( ResiliencePolicyRanges.MinEnabledBreakerFailureThreshold); diag.ShouldNotBeNull(); diag.ShouldContain("breakerFailureThreshold"); } /// A negative breaker threshold disables the breaker (0) with a diagnostic. [Fact] public void BreakerThreshold_Negative_DisablesBreaker() { var options = DriverResilienceOptionsParser.ParseOrDefaults( DriverTier.A, "{\"capabilityPolicies\":{\"Read\":{\"breakerFailureThreshold\":-5}}}", out var diag); options.Resolve(DriverCapability.Read).BreakerFailureThreshold.ShouldBe(0); diag.ShouldNotBeNull(); diag.ShouldContain("breakerFailureThreshold"); } /// Multiple clamps in one policy all surface in the (appended) diagnostic string. [Fact] public void MultipleClamps_AllSurfaceInDiagnostic() { var options = DriverResilienceOptionsParser.ParseOrDefaults( DriverTier.A, "{\"capabilityPolicies\":{\"Read\":{\"timeoutSeconds\":0,\"retryCount\":-1,\"breakerFailureThreshold\":1}}}", out var diag); diag.ShouldNotBeNull(); diag.ShouldContain("timeoutSeconds"); diag.ShouldContain("retryCount"); diag.ShouldContain("breakerFailureThreshold"); var read = options.Resolve(DriverCapability.Read); read.TimeoutSeconds.ShouldBe(DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read].TimeoutSeconds); read.RetryCount.ShouldBe(0); read.BreakerFailureThreshold.ShouldBe(ResiliencePolicyRanges.MinEnabledBreakerFailureThreshold); } // ---- 01/S-8 = 03/S12: write-shaped capabilities never retry ---- /// A Write retryCount override is forced to 0 with a diagnostic (writes are non-idempotent). [Fact] public void WriteRetryOverride_IsForcedToZero_WithDiagnostic() { var options = DriverResilienceOptionsParser.ParseOrDefaults( DriverTier.A, "{\"capabilityPolicies\":{\"Write\":{\"retryCount\":5}}}", out var diag); options.Resolve(DriverCapability.Write).RetryCount.ShouldBe(0); diag.ShouldNotBeNull(); diag.ShouldContain("Write never retries"); } /// An AlarmAcknowledge retryCount override is forced to 0 with a diagnostic. [Fact] public void AlarmAcknowledgeRetryOverride_IsForcedToZero_WithDiagnostic() { var options = DriverResilienceOptionsParser.ParseOrDefaults( DriverTier.A, "{\"capabilityPolicies\":{\"AlarmAcknowledge\":{\"retryCount\":3}}}", out var diag); options.Resolve(DriverCapability.AlarmAcknowledge).RetryCount.ShouldBe(0); diag.ShouldNotBeNull(); diag.ShouldContain("AlarmAcknowledge never retries"); } /// A Read (idempotent) retryCount override is still honored — the invariant is write-shaped only. [Fact] public void ReadRetryOverride_StillHonored() { var options = DriverResilienceOptionsParser.ParseOrDefaults( DriverTier.A, "{\"capabilityPolicies\":{\"Read\":{\"retryCount\":5}}}", out var diag); options.Resolve(DriverCapability.Read).RetryCount.ShouldBe(5); diag.ShouldBeNull(); } /// In-range overrides pass through untouched with no diagnostic. [Fact] public void InRangeOverrides_PassThrough_NoDiagnostic() { var options = DriverResilienceOptionsParser.ParseOrDefaults( DriverTier.A, "{\"capabilityPolicies\":{\"Read\":{\"timeoutSeconds\":5,\"retryCount\":3,\"breakerFailureThreshold\":4}}}", out var diag); diag.ShouldBeNull(); var read = options.Resolve(DriverCapability.Read); read.TimeoutSeconds.ShouldBe(5); read.RetryCount.ShouldBe(3); read.BreakerFailureThreshold.ShouldBe(4); } }