save progress
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
// <copyright file="ConfigDiffTestBase.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user