Files
git.stella-ops.org/src/__Tests/__Libraries/StellaOps.Testing.ConfigDiff/ConfigDiffTestBase.cs

356 lines
13 KiB
C#

// <copyright file="ConfigDiffTestBase.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
// 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;
/// <summary>
/// Base class for tests that verify config changes produce expected behavioral deltas.
/// </summary>
public abstract class ConfigDiffTestBase
{
private readonly ILogger _logger;
private readonly ConfigDiffTestConfig _config;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigDiffTestBase"/> class.
/// </summary>
/// <param name="config">Test configuration.</param>
/// <param name="logger">Logger instance.</param>
protected ConfigDiffTestBase(ConfigDiffTestConfig? config = null, ILogger? logger = null)
{
_config = config ?? new ConfigDiffTestConfig();
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
}
/// <summary>
/// Test that changing only config (no code) produces expected behavioral delta.
/// </summary>
/// <typeparam name="TConfig">Type of configuration.</typeparam>
/// <typeparam name="TBehavior">Type of behavior snapshot.</typeparam>
/// <param name="baselineConfig">Baseline configuration.</param>
/// <param name="changedConfig">Changed configuration.</param>
/// <param name="getBehavior">Function to capture behavior from configuration.</param>
/// <param name="computeDelta">Function to compute delta between behaviors.</param>
/// <param name="expectedDelta">Expected behavioral delta.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Test result.</returns>
protected async Task<ConfigDiffTestResult> TestConfigBehavioralDeltaAsync<TConfig, TBehavior>(
TConfig baselineConfig,
TConfig changedConfig,
Func<TConfig, Task<TBehavior>> getBehavior,
Func<TBehavior, TBehavior, ConfigDelta> 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);
}
/// <summary>
/// Test that config change does not affect unrelated behaviors.
/// </summary>
/// <typeparam name="TConfig">Type of configuration.</typeparam>
/// <param name="baselineConfig">Baseline configuration.</param>
/// <param name="changedConfig">Changed configuration.</param>
/// <param name="changedSetting">Name of the setting that was changed.</param>
/// <param name="unrelatedBehaviors">Functions to capture behaviors that should not change.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Test result.</returns>
protected async Task<ConfigDiffTestResult> TestConfigIsolationAsync<TConfig>(
TConfig baselineConfig,
TConfig changedConfig,
string changedSetting,
IEnumerable<Func<TConfig, Task<object>>> unrelatedBehaviors,
CancellationToken ct = default)
where TConfig : notnull
{
_logger.LogInformation("Testing config isolation for setting: {Setting}", changedSetting);
var unexpectedChanges = new List<string>();
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: []);
}
/// <summary>
/// Assert that actual delta matches expected delta.
/// </summary>
/// <param name="actual">Actual delta.</param>
/// <param name="expected">Expected delta.</param>
/// <returns>Test result.</returns>
protected ConfigDiffTestResult AssertDeltaMatches(ConfigDelta actual, ConfigDelta expected)
{
var unexpectedChanges = new List<string>();
var missingChanges = new List<string>();
// 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]);
}
/// <summary>
/// Compare behavior snapshot and generate delta.
/// </summary>
/// <param name="baseline">Baseline snapshot.</param>
/// <param name="changed">Changed snapshot.</param>
/// <returns>Config delta.</returns>
protected static ConfigDelta ComputeBehaviorSnapshotDelta(
BehaviorSnapshot baseline,
BehaviorSnapshot changed)
{
var changedBehaviors = new List<string>();
var deltas = new List<BehaviorDelta>();
// 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]);
}
/// <summary>
/// Create a behavior snapshot builder.
/// </summary>
/// <param name="configurationId">Configuration identifier.</param>
/// <returns>Behavior snapshot builder.</returns>
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;
}
}
/// <summary>
/// Builder for behavior snapshots.
/// </summary>
public sealed class BehaviorSnapshotBuilder
{
private readonly string _configurationId;
private readonly List<CapturedBehavior> _behaviors = [];
private DateTimeOffset _capturedAt = DateTimeOffset.UtcNow;
/// <summary>
/// Initializes a new instance of the <see cref="BehaviorSnapshotBuilder"/> class.
/// </summary>
/// <param name="configurationId">Configuration identifier.</param>
public BehaviorSnapshotBuilder(string configurationId)
{
_configurationId = configurationId;
}
/// <summary>
/// Add a captured behavior.
/// </summary>
/// <param name="name">Behavior name.</param>
/// <param name="value">Behavior value.</param>
/// <returns>This builder for chaining.</returns>
public BehaviorSnapshotBuilder AddBehavior(string name, string value)
{
_behaviors.Add(new CapturedBehavior(name, value, _capturedAt));
return this;
}
/// <summary>
/// Add a captured behavior with object value.
/// </summary>
/// <param name="name">Behavior name.</param>
/// <param name="value">Behavior value (will be converted to string).</param>
/// <returns>This builder for chaining.</returns>
public BehaviorSnapshotBuilder AddBehavior(string name, object? value)
{
return AddBehavior(name, value?.ToString() ?? "null");
}
/// <summary>
/// Set the capture timestamp.
/// </summary>
/// <param name="capturedAt">Capture timestamp.</param>
/// <returns>This builder for chaining.</returns>
public BehaviorSnapshotBuilder WithCapturedAt(DateTimeOffset capturedAt)
{
_capturedAt = capturedAt;
return this;
}
/// <summary>
/// Build the behavior snapshot.
/// </summary>
/// <returns>Behavior snapshot.</returns>
public BehaviorSnapshot Build()
{
return new BehaviorSnapshot(
ConfigurationId: _configurationId,
Behaviors: [.. _behaviors],
CapturedAt: _capturedAt);
}
}