//
// 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);
}
}