using System.Collections;
using System.Reflection;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
///
/// Structural deep-equality assertion for the site-command round-trip goldens.
///
///
///
/// Record equality is NOT usable here. A C# record's generated Equals
/// compares members with EqualityComparer<T>.Default, which for
/// IReadOnlyList<T> / IReadOnlyDictionary<,> members
/// degrades to reference equality — so Assert.Equal(dto, roundTripped)
/// would fail on every collection-bearing message even when the mapper is
/// perfect, and (worse) would pass vacuously nowhere useful. This walker
/// compares by shape instead: dictionaries by key, sequences element-wise, and
/// everything else property-by-property down to leaf values.
///
///
/// The failure message carries the full property path, so a dropped field
/// reports as e.g. DeployArtifactsCommand.ExternalSystems[0].TimeoutSeconds
/// rather than an opaque "objects differ".
///
///
internal static class StructuralEquality
{
/// Asserts two object graphs are structurally identical.
/// The original value.
/// The value that came back through the mapper.
/// Name used as the root of the reported property path.
public static void AssertDeepEqual(object? expected, object? actual, string rootName)
=> Compare(expected, actual, rootName);
private static void Compare(object? expected, object? actual, string path)
{
if (expected is null && actual is null)
{
return;
}
if (expected is null || actual is null)
{
Assert.Fail($"{path}: expected {Describe(expected)} but got {Describe(actual)}.");
return;
}
// JsonElement is the documented lossy escape hatch of LooseValueCodec; it
// has no useful Equals, so compare the canonical JSON text.
if (expected is JsonElement expectedJson && actual is JsonElement actualJson)
{
Assert.Equal(expectedJson.GetRawText(), actualJson.GetRawText());
return;
}
// Collections are compared by CONTENT, not by concrete container type.
// Members are declared IReadOnlyList/IReadOnlyDictionary<,>, so the
// backing type (a collection-expression array here, a List out of the
// mapper) is not part of the contract. Element and value types below are
// still compared strictly.
if (expected is not string && actual is not string)
{
if (expected is IDictionary expectedMap && actual is IDictionary actualMap)
{
CompareDictionaries(expectedMap, actualMap, path);
return;
}
if (expected is IEnumerable expectedSeq && actual is IEnumerable actualSeq)
{
CompareSequences(expectedSeq, actualSeq, path);
return;
}
}
var expectedType = expected.GetType();
var actualType = actual.GetType();
if (expectedType != actualType)
{
Assert.Fail(
$"{path}: type changed across the round trip — expected {expectedType.Name}, got {actualType.Name}.");
return;
}
if (IsLeaf(expectedType))
{
Assert.True(
Equals(expected, actual),
$"{path}: expected '{expected}' but got '{actual}'.");
return;
}
CompareProperties(expected, actual, expectedType, path);
}
private static void CompareDictionaries(IDictionary expected, IDictionary actual, string path)
{
Assert.True(
expected.Count == actual.Count,
$"{path}: entry count changed — expected {expected.Count}, got {actual.Count}.");
foreach (DictionaryEntry entry in expected)
{
Assert.True(
actual.Contains(entry.Key),
$"{path}: key '{entry.Key}' is missing after the round trip.");
Compare(entry.Value, actual[entry.Key], $"{path}['{entry.Key}']");
}
}
private static void CompareSequences(IEnumerable expected, IEnumerable actual, string path)
{
var expectedItems = expected.Cast