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().ToList(); var actualItems = actual.Cast().ToList(); Assert.True( expectedItems.Count == actualItems.Count, $"{path}: element count changed — expected {expectedItems.Count}, got {actualItems.Count}."); for (var i = 0; i < expectedItems.Count; i++) { Compare(expectedItems[i], actualItems[i], $"{path}[{i}]"); } } private static void CompareProperties(object expected, object actual, Type type, string path) { var properties = type .GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.CanRead && p.GetIndexParameters().Length == 0) .ToList(); Assert.True(properties.Count > 0, $"{path}: {type.Name} exposes no readable properties to compare."); foreach (var property in properties) { Compare(property.GetValue(expected), property.GetValue(actual), $"{path}.{property.Name}"); } } private static bool IsLeaf(Type type) => type.IsPrimitive || type.IsEnum || type == typeof(string) || type == typeof(decimal) || type == typeof(DateTime) || type == typeof(DateTimeOffset) || type == typeof(TimeSpan) || type == typeof(Guid) // Value types with no collection members (e.g. TrackedOperationId) have a // correct structural Equals of their own. || (type.IsValueType && !type.IsGenericType); private static string Describe(object? value) => value is null ? "" : $"{value.GetType().Name}('{value}')"; }