// // Copyright (c) StellaOps. Licensed under BUSL-1.1. // // Sprint: SPRINT_20260105_002_005_TEST_cross_cutting // Task: CCUT-019 using System.Collections.Immutable; using System.Globalization; using FluentAssertions; using Microsoft.Extensions.Logging; namespace StellaOps.Testing.ConfigDiff; /// /// Base class for tests that verify config changes produce expected behavioral deltas. /// public abstract class ConfigDiffTestBase { private readonly ILogger _logger; private readonly ConfigDiffTestConfig _config; /// /// Initializes a new instance of the class. /// /// Test configuration. /// Logger instance. protected ConfigDiffTestBase(ConfigDiffTestConfig? config = null, ILogger? logger = null) { _config = config ?? new ConfigDiffTestConfig(); _logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; } /// /// Test that changing only config (no code) produces expected behavioral delta. /// /// Type of configuration. /// Type of behavior snapshot. /// Baseline configuration. /// Changed configuration. /// Function to capture behavior from configuration. /// Function to compute delta between behaviors. /// Expected behavioral delta. /// Cancellation token. /// Test result. protected async Task TestConfigBehavioralDeltaAsync( TConfig baselineConfig, TConfig changedConfig, Func> getBehavior, Func computeDelta, ConfigDelta expectedDelta, CancellationToken ct = default) where TConfig : notnull where TBehavior : notnull { _logger.LogInformation("Testing config behavioral delta"); // Get behavior with baseline config var baselineBehavior = await getBehavior(baselineConfig); _logger.LogDebug("Captured baseline behavior"); // Get behavior with changed config var changedBehavior = await getBehavior(changedConfig); _logger.LogDebug("Captured changed behavior"); // Compute actual delta var actualDelta = computeDelta(baselineBehavior, changedBehavior); _logger.LogDebug("Computed delta: {ChangedCount} behaviors changed", actualDelta.ChangedBehaviors.Length); // Compare expected vs actual return AssertDeltaMatches(actualDelta, expectedDelta); } /// /// Test that config change does not affect unrelated behaviors. /// /// Type of configuration. /// Baseline configuration. /// Changed configuration. /// Name of the setting that was changed. /// Functions to capture behaviors that should not change. /// Cancellation token. /// Test result. protected async Task TestConfigIsolationAsync( TConfig baselineConfig, TConfig changedConfig, string changedSetting, IEnumerable>> unrelatedBehaviors, CancellationToken ct = default) where TConfig : notnull { _logger.LogInformation("Testing config isolation for setting: {Setting}", changedSetting); var unexpectedChanges = new List(); foreach (var getBehavior in unrelatedBehaviors) { var baselineBehavior = await getBehavior(baselineConfig); var changedBehavior = await getBehavior(changedConfig); try { // Unrelated behaviors should be identical baselineBehavior.Should().BeEquivalentTo(changedBehavior, $"Changing '{changedSetting}' should not affect unrelated behavior"); } catch (Exception ex) { unexpectedChanges.Add($"Unexpected change in behavior: {ex.Message}"); } } return new ConfigDiffTestResult( IsSuccess: unexpectedChanges.Count == 0, ExpectedDelta: ConfigDelta.Empty, ActualDelta: unexpectedChanges.Count > 0 ? new ConfigDelta( [.. unexpectedChanges], [.. unexpectedChanges.Select(c => new BehaviorDelta(c, null, null, null))]) : ConfigDelta.Empty, UnexpectedChanges: [.. unexpectedChanges], MissingChanges: []); } /// /// Assert that actual delta matches expected delta. /// /// Actual delta. /// Expected delta. /// Test result. protected ConfigDiffTestResult AssertDeltaMatches(ConfigDelta actual, ConfigDelta expected) { var unexpectedChanges = new List(); var missingChanges = new List(); // Check for unexpected changes foreach (var actualChange in actual.ChangedBehaviors) { if (_config.IgnoreBehaviors.Contains(actualChange)) { continue; } if (!expected.ChangedBehaviors.Contains(actualChange)) { unexpectedChanges.Add(actualChange); _logger.LogWarning("Unexpected behavior change: {Behavior}", actualChange); } } // Check for missing expected changes foreach (var expectedChange in expected.ChangedBehaviors) { if (!actual.ChangedBehaviors.Contains(expectedChange)) { missingChanges.Add(expectedChange); _logger.LogWarning("Missing expected behavior change: {Behavior}", expectedChange); } } // Verify actual change values match expected foreach (var expectedDelta in expected.BehaviorDeltas) { var actualDelta = actual.BehaviorDeltas .FirstOrDefault(d => d.BehaviorName == expectedDelta.BehaviorName); if (actualDelta != null && expectedDelta.NewValue != null) { if (!ValuesMatch(actualDelta.NewValue, expectedDelta.NewValue)) { unexpectedChanges.Add( $"{expectedDelta.BehaviorName}: expected '{expectedDelta.NewValue}', got '{actualDelta.NewValue}'"); } } } var isSuccess = unexpectedChanges.Count == 0 && missingChanges.Count == 0; if (isSuccess) { _logger.LogInformation("Config diff test passed"); } else { _logger.LogError( "Config diff test failed: {Unexpected} unexpected, {Missing} missing", unexpectedChanges.Count, missingChanges.Count); } return new ConfigDiffTestResult( IsSuccess: isSuccess, ExpectedDelta: expected, ActualDelta: actual, UnexpectedChanges: [.. unexpectedChanges], MissingChanges: [.. missingChanges]); } /// /// Compare behavior snapshot and generate delta. /// /// Baseline snapshot. /// Changed snapshot. /// Config delta. protected static ConfigDelta ComputeBehaviorSnapshotDelta( BehaviorSnapshot baseline, BehaviorSnapshot changed) { var changedBehaviors = new List(); var deltas = new List(); // Find changed behaviors foreach (var changedBehavior in changed.Behaviors) { var baselineBehavior = baseline.Behaviors .FirstOrDefault(b => b.Name == changedBehavior.Name); if (baselineBehavior == null) { // New behavior changedBehaviors.Add(changedBehavior.Name); deltas.Add(new BehaviorDelta( changedBehavior.Name, null, changedBehavior.Value, "New behavior")); } else if (baselineBehavior.Value != changedBehavior.Value) { // Changed behavior changedBehaviors.Add(changedBehavior.Name); deltas.Add(new BehaviorDelta( changedBehavior.Name, baselineBehavior.Value, changedBehavior.Value, null)); } } // Find removed behaviors foreach (var baselineBehavior in baseline.Behaviors) { var changedBehavior = changed.Behaviors .FirstOrDefault(b => b.Name == baselineBehavior.Name); if (changedBehavior == null) { changedBehaviors.Add(baselineBehavior.Name); deltas.Add(new BehaviorDelta( baselineBehavior.Name, baselineBehavior.Value, null, "Removed behavior")); } } return new ConfigDelta([.. changedBehaviors], [.. deltas]); } /// /// Create a behavior snapshot builder. /// /// Configuration identifier. /// Behavior snapshot builder. protected static BehaviorSnapshotBuilder CreateSnapshotBuilder(string configurationId) { return new BehaviorSnapshotBuilder(configurationId); } private bool ValuesMatch(string? actual, string? expected) { if (actual == expected) { return true; } if (actual == null || expected == null) { return false; } // Try numeric comparison with tolerance if (_config.ValueComparisonTolerance > 0 && decimal.TryParse(actual, NumberStyles.Float, CultureInfo.InvariantCulture, out var actualNum) && decimal.TryParse(expected, NumberStyles.Float, CultureInfo.InvariantCulture, out var expectedNum)) { return Math.Abs(actualNum - expectedNum) <= _config.ValueComparisonTolerance; } return false; } } /// /// Builder for behavior snapshots. /// public sealed class BehaviorSnapshotBuilder { private readonly string _configurationId; private readonly List _behaviors = []; private DateTimeOffset _capturedAt = DateTimeOffset.UtcNow; /// /// Initializes a new instance of the class. /// /// Configuration identifier. public BehaviorSnapshotBuilder(string configurationId) { _configurationId = configurationId; } /// /// Add a captured behavior. /// /// Behavior name. /// Behavior value. /// This builder for chaining. public BehaviorSnapshotBuilder AddBehavior(string name, string value) { _behaviors.Add(new CapturedBehavior(name, value, _capturedAt)); return this; } /// /// Add a captured behavior with object value. /// /// Behavior name. /// Behavior value (will be converted to string). /// This builder for chaining. public BehaviorSnapshotBuilder AddBehavior(string name, object? value) { return AddBehavior(name, value?.ToString() ?? "null"); } /// /// Set the capture timestamp. /// /// Capture timestamp. /// This builder for chaining. public BehaviorSnapshotBuilder WithCapturedAt(DateTimeOffset capturedAt) { _capturedAt = capturedAt; return this; } /// /// Build the behavior snapshot. /// /// Behavior snapshot. public BehaviorSnapshot Build() { return new BehaviorSnapshot( ConfigurationId: _configurationId, Behaviors: [.. _behaviors], CapturedAt: _capturedAt); } }