From 90cb55473429cace75057bfecd50a752e96517c2 Mon Sep 17 00:00:00 2001 From: DifferentLevelDan Date: Thu, 2 Nov 2023 18:18:51 -0500 Subject: [PATCH 1/2] Resolves issue #381 Implemented a reflection equality checker for DataBlobs. Removed IGetValueHash and IEquatable<> from DataBlobs, as they are no longer needed. --- .../GameEngine/Datablobs/AsteroidDamageDB.cs | 33 +------------ Pulsar4X/GameEngine/Datablobs/AtmosphereDB.cs | 16 ------- Pulsar4X/GameEngine/Datablobs/BaseDataBlob.cs | 7 +-- .../Datablobs/FactionAbilitiesDB.cs | 18 +------ .../GameEngine/Datablobs/FactionInfoDB.cs | 48 +------------------ .../GameEngine/Datablobs/FactionOwnerDB.cs | 12 +---- .../GameEngine/Datablobs/FactionTechDB.cs | 8 +--- Pulsar4X/GameEngine/Datablobs/MassVolumeDB.cs | 8 +--- Pulsar4X/GameEngine/Datablobs/NameDB.cs | 12 +---- Pulsar4X/GameEngine/Datablobs/OrbitDB.cs | 12 +---- Pulsar4X/GameEngine/Datablobs/PositionDB.cs | 8 +--- Pulsar4X/GameEngine/Datablobs/SensorInfoDB.cs | 14 +----- .../GameEngine/Datablobs/SystemBodyInfoDB.cs | 18 +------ Pulsar4X/Pulsar4X.Tests/DataBlobTests.cs | 11 +---- Pulsar4X/Pulsar4X.Tests/SystemGenTests.cs | 18 ++----- Pulsar4X/Pulsar4X.Tests/TestingUtilities.cs | 31 ++++++++++++ 16 files changed, 48 insertions(+), 226 deletions(-) diff --git a/Pulsar4X/GameEngine/Datablobs/AsteroidDamageDB.cs b/Pulsar4X/GameEngine/Datablobs/AsteroidDamageDB.cs index 008c54f3a..352758f52 100644 --- a/Pulsar4X/GameEngine/Datablobs/AsteroidDamageDB.cs +++ b/Pulsar4X/GameEngine/Datablobs/AsteroidDamageDB.cs @@ -1,15 +1,10 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Runtime.Serialization; using Newtonsoft.Json; using Pulsar4X.DataStructures; namespace Pulsar4X.Datablobs { - public class AsteroidDamageDB : BaseDataBlob, IEquatable + public class AsteroidDamageDB : BaseDataBlob { /// /// Asteroids are damageable and need to store their health value. @@ -58,31 +53,5 @@ public bool Equals(AsteroidDamageDB? other) return true; return _health == other._health && FractureChance.Equals(other.FractureChance); } - - public override bool Equals(object? obj) - { - if (ReferenceEquals(null, obj)) - return false; - if (ReferenceEquals(this, obj)) - return true; - if (obj.GetType() != this.GetType()) - return false; - return Equals((AsteroidDamageDB)obj); - } - - public override int GetHashCode() - { - return HashCode.Combine(_health, FractureChance); - } - - public static bool operator ==(AsteroidDamageDB? left, AsteroidDamageDB? right) - { - return Equals(left, right); - } - - public static bool operator !=(AsteroidDamageDB? left, AsteroidDamageDB? right) - { - return !Equals(left, right); - } } } diff --git a/Pulsar4X/GameEngine/Datablobs/AtmosphereDB.cs b/Pulsar4X/GameEngine/Datablobs/AtmosphereDB.cs index 3d51d7b94..541f61dcd 100644 --- a/Pulsar4X/GameEngine/Datablobs/AtmosphereDB.cs +++ b/Pulsar4X/GameEngine/Datablobs/AtmosphereDB.cs @@ -178,21 +178,5 @@ public override object Clone() { return new AtmosphereDB(this); } - - public override bool Equals(object? obj) - { - if(!base.Equals(obj)) return false; - - AtmosphereDB other = (AtmosphereDB)obj; - if(this.Pressure != other.Pressure) return false; - if(this.Hydrosphere != other.Hydrosphere) return false; - if(this.HydrosphereExtent != other.HydrosphereExtent) return false; - if(this.GreenhouseFactor != other.GreenhouseFactor) return false; - if(this.GreenhousePressure != other.GreenhousePressure) return false; - if(this.SurfaceTemperature != other.SurfaceTemperature) return false; - if(!(this.Composition.Count == other.Composition.Count && !this.Composition.Except(other.Composition).Any())) return false; - - return true; - } } } diff --git a/Pulsar4X/GameEngine/Datablobs/BaseDataBlob.cs b/Pulsar4X/GameEngine/Datablobs/BaseDataBlob.cs index 6173d8827..cc14efd41 100644 --- a/Pulsar4X/GameEngine/Datablobs/BaseDataBlob.cs +++ b/Pulsar4X/GameEngine/Datablobs/BaseDataBlob.cs @@ -6,7 +6,7 @@ namespace Pulsar4X.Datablobs { [JsonObject(MemberSerialization.OptIn)] - public abstract class BaseDataBlob : ICloneable, IEquatable + public abstract class BaseDataBlob : ICloneable { /// /// This is the Entity which Owns/Conatains/IsParentOf this datablob @@ -36,11 +36,6 @@ public bool Equals(BaseDataBlob? other) } } - public interface IGetValuesHash - { - int GetValueCompareHash(int hash = 17); - } - public interface IAbilityDescription { string AbilityName(); diff --git a/Pulsar4X/GameEngine/Datablobs/FactionAbilitiesDB.cs b/Pulsar4X/GameEngine/Datablobs/FactionAbilitiesDB.cs index 4d0c5e454..6ff7982b5 100644 --- a/Pulsar4X/GameEngine/Datablobs/FactionAbilitiesDB.cs +++ b/Pulsar4X/GameEngine/Datablobs/FactionAbilitiesDB.cs @@ -9,7 +9,7 @@ namespace Pulsar4X.Datablobs { - public class FactionAbilitiesDB : BaseDataBlob, IGetValuesHash + public class FactionAbilitiesDB : BaseDataBlob { public int BasePlanetarySensorStrength { get; set; } @@ -72,21 +72,5 @@ public override object Clone() { return new FactionAbilitiesDB(this); } - - public int GetValueCompareHash(int hash = 17) - { - - foreach (var item in AbilityBonuses) - { - hash = ObjectExtensions.ValueHash(item.Key, hash); - hash = ObjectExtensions.ValueHash(item.Value, hash); - } - - hash = ObjectExtensions.ValueHash(BasePlanetarySensorStrength, hash); - hash = ObjectExtensions.ValueHash(BaseGroundUnitStrengthBonus, hash); - hash = ObjectExtensions.ValueHash(ColonyCostMultiplier, hash); - return hash; - } - } } \ No newline at end of file diff --git a/Pulsar4X/GameEngine/Datablobs/FactionInfoDB.cs b/Pulsar4X/GameEngine/Datablobs/FactionInfoDB.cs index e29b1c877..c339bf188 100644 --- a/Pulsar4X/GameEngine/Datablobs/FactionInfoDB.cs +++ b/Pulsar4X/GameEngine/Datablobs/FactionInfoDB.cs @@ -17,7 +17,7 @@ namespace Pulsar4X.Datablobs { - public class FactionInfoDB : BaseDataBlob, IGetValuesHash + public class FactionInfoDB : BaseDataBlob { [JsonProperty] public FactionDataStore Data { get; internal set; } = new FactionDataStore(); @@ -149,51 +149,5 @@ void SetIndustryDesigns( IndustryDesigns[design.Key] = design.Value; } } - - [OnDeserialized] - public void OnDeserialized(StreamingContext context) - { - //((Game)context.Context).PostLoad += (sender, args) => { }; - } - - public int GetValueCompareHash(int hash = 17) - { - foreach (var item in Species) - { - hash = ObjectExtensions.ValueHash(item.Id, hash); - } - foreach (var item in KnownSystems) - { - hash = ObjectExtensions.ValueHash(item, hash); - } - foreach (var item in KnownFactions) - { - hash = ObjectExtensions.ValueHash(item.Id, hash); - } - foreach (var item in Colonies) - { - hash = ObjectExtensions.ValueHash(item.Id, hash); - } - foreach (var item in ShipDesigns.Keys) - { - hash = ObjectExtensions.ValueHash(item, hash); - } - foreach (var item in InternalComponentDesigns) - { - hash = ObjectExtensions.ValueHash(item.Key, hash); - hash = ObjectExtensions.ValueHash(item.Value.UniqueID, hash); - } - foreach (var system in InternalKnownJumpPoints) - { - hash = ObjectExtensions.ValueHash(system.Key, hash); - foreach (var jp in system.Value) - { - hash = ObjectExtensions.ValueHash(jp.Id, hash); - } - - } - - return hash; - } } } \ No newline at end of file diff --git a/Pulsar4X/GameEngine/Datablobs/FactionOwnerDB.cs b/Pulsar4X/GameEngine/Datablobs/FactionOwnerDB.cs index 911e63371..e171010d7 100644 --- a/Pulsar4X/GameEngine/Datablobs/FactionOwnerDB.cs +++ b/Pulsar4X/GameEngine/Datablobs/FactionOwnerDB.cs @@ -6,7 +6,7 @@ namespace Pulsar4X.Datablobs { - public class FactionOwnerDB : BaseDataBlob, IGetValuesHash + public class FactionOwnerDB : BaseDataBlob { [JsonProperty] internal Dictionary OwnedEntities { get; set; } = new (); @@ -45,15 +45,5 @@ public override object Clone() { return new FactionOwnerDB(this); } - - public int GetValueCompareHash(int hash = 17) - { - foreach (var item in OwnedEntities) - { - hash = ObjectExtensions.ValueHash(item.Key, hash); - } - - return hash; - } } } \ No newline at end of file diff --git a/Pulsar4X/GameEngine/Datablobs/FactionTechDB.cs b/Pulsar4X/GameEngine/Datablobs/FactionTechDB.cs index 99f5d592c..e12d79ece 100644 --- a/Pulsar4X/GameEngine/Datablobs/FactionTechDB.cs +++ b/Pulsar4X/GameEngine/Datablobs/FactionTechDB.cs @@ -5,7 +5,7 @@ namespace Pulsar4X.Datablobs { - public class FactionTechDB : BaseDataBlob, IGetValuesHash + public class FactionTechDB : BaseDataBlob { [PublicAPI] [JsonProperty] @@ -25,11 +25,5 @@ public override object Clone() { return new FactionTechDB(this); } - - public int GetValueCompareHash(int hash = 17) - { - hash = ObjectExtensions.ValueHash(ResearchPoints, hash); - return hash; - } } } diff --git a/Pulsar4X/GameEngine/Datablobs/MassVolumeDB.cs b/Pulsar4X/GameEngine/Datablobs/MassVolumeDB.cs index f9ea16425..a8472a207 100644 --- a/Pulsar4X/GameEngine/Datablobs/MassVolumeDB.cs +++ b/Pulsar4X/GameEngine/Datablobs/MassVolumeDB.cs @@ -6,7 +6,7 @@ namespace Pulsar4X.Datablobs { - public class MassVolumeDB : BaseDataBlob, ISensorCloneMethod, IGetValuesHash + public class MassVolumeDB : BaseDataBlob, ISensorCloneMethod { /// @@ -263,11 +263,5 @@ void Update(MassVolumeDB originalDB, SensorInfoDB sensorInfo) RadiusInAU = originalDB.RadiusInAU; Volume_km3 = originalDB.Volume_km3; } - - public int GetValueCompareHash(int hash = 17) - { - hash = ObjectExtensions.ValueHash(DensityDry_gcm, hash); - return hash; - } } } diff --git a/Pulsar4X/GameEngine/Datablobs/NameDB.cs b/Pulsar4X/GameEngine/Datablobs/NameDB.cs index 94c98307a..828ffb477 100644 --- a/Pulsar4X/GameEngine/Datablobs/NameDB.cs +++ b/Pulsar4X/GameEngine/Datablobs/NameDB.cs @@ -9,7 +9,7 @@ namespace Pulsar4X.Datablobs { [DebuggerDisplay("{" + nameof(OwnersName) + "}")] - public class NameDB : BaseDataBlob, ISensorCloneMethod, IGetValuesHash + public class NameDB : BaseDataBlob, ISensorCloneMethod { /// @@ -87,16 +87,6 @@ public BaseDataBlob SensorClone(SensorInfoDB sensorInfo) return new NameDB(this, sensorInfo); } - public int GetValueCompareHash(int hash = 17) - { - foreach (var item in _names) - { - hash = ObjectExtensions.ValueHash(item.Key, hash); - hash = ObjectExtensions.ValueHash(item.Value, hash); - } - return hash; - } - public void SensorUpdate(SensorInfoDB sensorInfo) { //do nothing for this. diff --git a/Pulsar4X/GameEngine/Datablobs/OrbitDB.cs b/Pulsar4X/GameEngine/Datablobs/OrbitDB.cs index b6706bf33..24bfbb6d0 100644 --- a/Pulsar4X/GameEngine/Datablobs/OrbitDB.cs +++ b/Pulsar4X/GameEngine/Datablobs/OrbitDB.cs @@ -8,7 +8,7 @@ namespace Pulsar4X.Datablobs { - public class OrbitDB : TreeHierarchyDB, IGetValuesHash + public class OrbitDB : TreeHierarchyDB { public new static List GetDependencies() => new List() { typeof(PositionDB) }; @@ -548,16 +548,6 @@ void UpdateFromSensorInfo(OrbitDB actualDB, SensorInfoDB sensorInfo) _myMass = actualDB._myMass; CalculateExtendedParameters(); } - - public int GetValueCompareHash(int hash = 17) - { - hash = ObjectExtensions.ValueHash(SemiMajorAxis, hash); - hash = ObjectExtensions.ValueHash(Eccentricity, hash); - - - return hash; - } - #endregion } diff --git a/Pulsar4X/GameEngine/Datablobs/PositionDB.cs b/Pulsar4X/GameEngine/Datablobs/PositionDB.cs index c8ffc5e1d..d6d8fe0b9 100644 --- a/Pulsar4X/GameEngine/Datablobs/PositionDB.cs +++ b/Pulsar4X/GameEngine/Datablobs/PositionDB.cs @@ -9,7 +9,7 @@ namespace Pulsar4X.Datablobs { //TODO: get rid of AU, why are we using AU. - public class PositionDB : TreeHierarchyDB, IGetValuesHash, IPosition + public class PositionDB : TreeHierarchyDB, IPosition { [JsonProperty] @@ -154,11 +154,5 @@ public override object Clone() { return new PositionDB(this); } - - public int GetValueCompareHash(int hash = 17) - { - hash = ObjectExtensions.ValueHash(AbsolutePosition, hash); - return hash; - } } } diff --git a/Pulsar4X/GameEngine/Datablobs/SensorInfoDB.cs b/Pulsar4X/GameEngine/Datablobs/SensorInfoDB.cs index e5b27ffa9..5cb971497 100644 --- a/Pulsar4X/GameEngine/Datablobs/SensorInfoDB.cs +++ b/Pulsar4X/GameEngine/Datablobs/SensorInfoDB.cs @@ -10,7 +10,7 @@ namespace Pulsar4X.Datablobs /// This datablob goes into the sensor contact. /// TODO: I can't see this actualy getting added to an entity anywhere, maybe it does not need to be a datablob. /// - public class SensorInfoDB : BaseDataBlob, IGetValuesHash + public class SensorInfoDB : BaseDataBlob { [JsonProperty] internal Entity Faction; @@ -44,18 +44,6 @@ public override object Clone() return new SensorInfoDB(this); } - public int GetValueCompareHash(int hash = 17) - { - hash = ObjectExtensions.ValueHash(Faction, hash); - hash = ObjectExtensions.ValueHash(DetectedEntityID, hash); - hash = ObjectExtensions.ValueHash(SensorContact, hash); - hash = ObjectExtensions.ValueHash(LastDetection, hash); - hash = ObjectExtensions.ValueHash(LatestDetectionQuality, hash); - hash = ObjectExtensions.ValueHash(HighestDetectionQuality, hash); - - return hash; - } - internal SensorInfoDB(SensorInfoDB db) { Faction = db.Faction; diff --git a/Pulsar4X/GameEngine/Datablobs/SystemBodyInfoDB.cs b/Pulsar4X/GameEngine/Datablobs/SystemBodyInfoDB.cs index b7aaa7cbc..90218516a 100644 --- a/Pulsar4X/GameEngine/Datablobs/SystemBodyInfoDB.cs +++ b/Pulsar4X/GameEngine/Datablobs/SystemBodyInfoDB.cs @@ -15,7 +15,7 @@ namespace Pulsar4X.Datablobs /// /// Specifically, Minerals, body info, atmosphere info, and gravity. /// - public class SystemBodyInfoDB : BaseDataBlob, ISensorCloneMethod, IGetValuesHash + public class SystemBodyInfoDB : BaseDataBlob, ISensorCloneMethod { public new static List GetDependencies() => new List() { typeof(NameDB) }; /// @@ -186,22 +186,6 @@ void UpdateDatablob(SystemBodyInfoDB originalDB, SensorInfoDB sensorInfo) Colonies = new List(originalDB.Colonies); //this needs to only have owned colonies and sensor entites of unowned colonies. } - public int GetValueCompareHash(int hash = 17) - { - hash = ObjectExtensions.ValueHash(BodyType, hash); - hash = ObjectExtensions.ValueHash(Tectonics, hash); - hash = ObjectExtensions.ValueHash(AxialTilt, hash); - hash = ObjectExtensions.ValueHash(MagneticField, hash); - hash = ObjectExtensions.ValueHash(BaseTemperature, hash); - hash = ObjectExtensions.ValueHash(RadiationLevel, hash); - hash = ObjectExtensions.ValueHash(AtmosphericDust, hash); - hash = ObjectExtensions.ValueHash(SupportsPopulations, hash); - hash = ObjectExtensions.ValueHash(LengthOfDay, hash); - hash = ObjectExtensions.ValueHash(Gravity, hash); - //hash = ObjectExtensions.ValueHash(Minerals, hash); for some reason minerals were not hashing the same. - return hash; - } - SystemBodyInfoDB(SystemBodyInfoDB originalDB, SensorInfoDB sensorInfo) { UpdateDatablob(originalDB, sensorInfo); diff --git a/Pulsar4X/Pulsar4X.Tests/DataBlobTests.cs b/Pulsar4X/Pulsar4X.Tests/DataBlobTests.cs index 860d74f58..22edba119 100644 --- a/Pulsar4X/Pulsar4X.Tests/DataBlobTests.cs +++ b/Pulsar4X/Pulsar4X.Tests/DataBlobTests.cs @@ -65,20 +65,11 @@ public void AccessibilityTest(Type dataBlobType) { return; } + if (!dataBlobType.IsPublic) { Assert.IsNotNull(dataBlobType.GetCustomAttribute(true), "DataBlob is not public"); } } - - [Test] - [TestCaseSource(nameof(DataBlobTypes))] - public void AllDerivedTypesOfBaseDataBlobImplementIEquatable(Type derivedType) - { - var equatableType = typeof(IEquatable<>).MakeGenericType(derivedType); - bool implementsEquatable = derivedType.GetInterfaces().Any(i => i == equatableType); - - Assert.IsTrue(implementsEquatable, $"{derivedType.Name} does not implement IEquatable<{derivedType.Name}>."); - } } } \ No newline at end of file diff --git a/Pulsar4X/Pulsar4X.Tests/SystemGenTests.cs b/Pulsar4X/Pulsar4X.Tests/SystemGenTests.cs index e1701f5d3..8ec15a121 100644 --- a/Pulsar4X/Pulsar4X.Tests/SystemGenTests.cs +++ b/Pulsar4X/Pulsar4X.Tests/SystemGenTests.cs @@ -190,7 +190,7 @@ public void CreateAndCheckDeterministic() Entity entityTwin = orbitTwins[i]; var db1 = entityPrime.GetDataBlob(); var db2 = entityTwin.GetDataBlob(); - Assert.AreEqual(db1.GetValueCompareHash(), db2.GetValueCompareHash()); + Assert.IsTrue(DataBlobEqualityChecker.AreEqual(db1, db2)); List entityPrimeDataBlobs = entityPrime.Manager.GetAllDataBlobsForEntity(entityPrime.Id); List entityTwinDataBlobs = entityTwin.Manager.GetAllDataBlobsForEntity(entityTwin.Id); @@ -199,19 +199,7 @@ public void CreateAndCheckDeterministic() { BaseDataBlob blob1 = entityPrimeDataBlobs[j]; BaseDataBlob blob2 = entityTwinDataBlobs[j]; - Assert.IsTrue(blob1.GetType().ToString() == blob2.GetType().ToString()); - - - var getValuesHashBlob1 = blob1 as IGetValuesHash; - var getValuesHashBlob2 = blob1 as IGetValuesHash; - // TODO: Temporary Workaround, not all DataBlobs implement IGetValuesHash. - // See Issue #381 - #warning Undefined Equality Checks for DataBlob in Deterministic Test - if (getValuesHashBlob1 == null) - continue; - int hashBlob1 = getValuesHashBlob1.GetValueCompareHash(); - int hashBlob2 = getValuesHashBlob2.GetValueCompareHash(); - Assert.AreEqual(hashBlob1, hashBlob2, "Hashes for iteration" + j + " type " + blob1.GetType() + "Don't match"); + Assert.IsTrue(DataBlobEqualityChecker.AreEqual(blob1, blob2)); } } } @@ -407,4 +395,6 @@ public void JPConnectivity() } } } + + } \ No newline at end of file diff --git a/Pulsar4X/Pulsar4X.Tests/TestingUtilities.cs b/Pulsar4X/Pulsar4X.Tests/TestingUtilities.cs index cbaddec4c..63f6461ee 100644 --- a/Pulsar4X/Pulsar4X.Tests/TestingUtilities.cs +++ b/Pulsar4X/Pulsar4X.Tests/TestingUtilities.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Text; using System.Threading.Tasks; using Pulsar4X.Engine; @@ -160,4 +161,34 @@ internal TestGame(int numSystems = 10) } + + public static class DataBlobEqualityChecker + { + public static bool AreEqual(T first, T second) where T : BaseDataBlob + { + // Get the actual implementation type, not a base type. + Type firstType = first.GetType(); + Type secondType = second.GetType(); + + if (firstType != secondType) return false; + + // Reflect equality checks across the properties. + foreach (PropertyInfo property in firstType.GetProperties()) + { + object? firstValue = property.GetValue(first); + object? secondValue = property.GetValue(second); + + if (firstValue is null && secondValue is null) + continue; + + if (firstValue is null || secondValue is null) + return false; + + if (!firstValue.Equals(secondValue)) + return false; + } + + return true; + } + } } From 9e521d0713d6964735786264d2dbb38f914d96b6 Mon Sep 17 00:00:00 2001 From: DifferentLevelDan Date: Thu, 2 Nov 2023 23:24:01 -0500 Subject: [PATCH 2/2] Reflect Deeper in Deterministic Unit Test - Determined Mineral Generation is not Deterministic. --- Pulsar4X/Pulsar4X.Tests/TestingUtilities.cs | 176 +++++++++++++++++++- 1 file changed, 172 insertions(+), 4 deletions(-) diff --git a/Pulsar4X/Pulsar4X.Tests/TestingUtilities.cs b/Pulsar4X/Pulsar4X.Tests/TestingUtilities.cs index 63f6461ee..915907595 100644 --- a/Pulsar4X/Pulsar4X.Tests/TestingUtilities.cs +++ b/Pulsar4X/Pulsar4X.Tests/TestingUtilities.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using System.Reflection; @@ -162,10 +163,33 @@ internal TestGame(int numSystems = 10) } + /// + /// Provides methods to check the "equivalence" of data blobs. + /// Note: This is not a true equality checker, but a utility to check + /// if two data blobs are effectively equivalent for testing purposes. + /// public static class DataBlobEqualityChecker { + /// + /// Determines if two data blobs are effectively equivalent. + /// + /// The type of the data blobs. + /// The first data blob. + /// The second data blob. + /// true if the two data blobs are effectively equivalent; otherwise, false. public static bool AreEqual(T first, T second) where T : BaseDataBlob { + // Use a HashSet to track compared dataBlobs. + var visitedPairs = new HashSet<(object?, object?)>(); + return AreEqualInternal(first, second, visitedPairs); + } + private static bool AreEqualInternal(T first, T second, HashSet<(object?, object?)> visitedPairs) where T : BaseDataBlob + { + // Avoid infinite recursion + if (visitedPairs.Contains((first, second)) || visitedPairs.Contains((second, first))) + return true; + visitedPairs.Add((first, second)); + // Get the actual implementation type, not a base type. Type firstType = first.GetType(); Type secondType = second.GetType(); @@ -177,18 +201,162 @@ public static bool AreEqual(T first, T second) where T : BaseDataBlob { object? firstValue = property.GetValue(first); object? secondValue = property.GetValue(second); + if (!CompareProperty(visitedPairs, firstValue, secondValue)) + return false; + } - if (firstValue is null && secondValue is null) - continue; + return true; + } + + private static bool CompareProperty(HashSet<(object?, object?)> visitedPairs, object? firstValue, object? secondValue) + { + if (visitedPairs.Contains((firstValue, secondValue)) || visitedPairs.Contains((secondValue, firstValue))) + return true; + + visitedPairs.Add((firstValue, secondValue)); + + if (firstValue is null && secondValue is null) + return true; + + if (firstValue is null || secondValue is null) + return false; + + Type propertyType = firstValue.GetType(); + if (propertyType != secondValue.GetType()) + return false; + + // Special handling for Entities so we don't check references + if (propertyType == typeof(Entity)) + { + if (!EntityPropertiesMatch((Entity)firstValue, (Entity)secondValue, visitedPairs)) + { + return false; + } + } + else if (firstValue.GetType().IsValueType || firstValue is string) + { + if (!firstValue.Equals(secondValue)) // For value types and strings, a direct equality check suffices. + return false; + } + else if (propertyType.IsSubclassOf(typeof(BaseDataBlob))) + { + if (!AreEqualInternal((BaseDataBlob)firstValue, (BaseDataBlob)secondValue, visitedPairs)) + return false; + } + else if (propertyType.IsGenericType && propertyType.GetGenericTypeDefinition() == typeof(List<>)) + { + var firstList = (IList)firstValue; + var secondList = (IList)secondValue; + + if (firstList.Count != secondList.Count) return false; + + for (int i = 0; i < firstList.Count; i++) + { + object? firstListItem = firstList[i]; + object? secondListItem = secondList[i]; + if (!CompareProperty(visitedPairs, firstListItem, secondListItem)) + return false; + + } + } + // If the property type is a generic dictionary, we need to handle it specially. + else if (propertyType.IsGenericType && propertyType.GetGenericTypeDefinition() == typeof(Dictionary<,>)) + { + var firstDict = (IDictionary)firstValue; + var secondDict = (IDictionary)secondValue; + + // If the dictionaries have different counts, they're not equivalent. + if (firstDict.Count != secondDict.Count) return false; + + // For each key in the first dictionary... + foreach (object? firstKey in firstDict.Keys) + { + bool keyMatchFound = false; + + // ...we loop through all keys in the second dictionary to find an equivalent. + foreach (object? secondKey in secondDict.Keys) + { + // Use CompareProperty to check if keys from both dictionaries are effectively equivalent. + if (!CompareProperty(visitedPairs, firstKey, secondKey)) + continue; + + keyMatchFound = true; + + // If keys are equivalent, retrieve the values for both keys. + object? firstDictValue = firstDict[firstKey]; + object? secondDictValue = secondDict[secondKey]; + + // Now compare the values for the matched keys. If values aren't equivalent, entire dictionaries aren't equivalent. + if (!CompareProperty(visitedPairs, firstDictValue, secondDictValue)) + return false; - if (firstValue is null || secondValue is null) + break; // Break out of inner loop as a matching key was found. + } + + // If no equivalent key was found in the second dictionary for a key in the first dictionary, they're not equivalent. + if (!keyMatchFound) return false; + } + } + else if (propertyType.IsClass) + return ReflectAndCompareClass(visitedPairs, firstValue, secondValue); + else if (!firstValue.Equals(secondValue)) + return false; + + return true; + } + + private static bool ReflectAndCompareClass(HashSet<(object?, object?)> visitedPairs, object firstObject, object secondObject) + { + Type objectType = firstObject.GetType(); + + // Compare fields. + foreach (FieldInfo field in objectType.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + { + object? firstFieldValue = field.GetValue(firstObject); + object? secondFieldValue = field.GetValue(secondObject); + if (!CompareProperty(visitedPairs, firstFieldValue, secondFieldValue)) return false; + } - if (!firstValue.Equals(secondValue)) + // Compare properties. + foreach (PropertyInfo property in objectType.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + { + object? firstPropertyValue = property.GetValue(firstObject); + object? secondPropertyValue = property.GetValue(secondObject); + if (!CompareProperty(visitedPairs, firstPropertyValue, secondPropertyValue)) return false; } return true; } + + /// + /// Checks if the properties of two entities make them effectively equivalent. + /// + /// The first entity. + /// The second entity. + /// /// A collection used to track already compared pairs of data blobs, preventing infinite recursion when circular references are present. + /// true if the two entities are effectively equivalent; otherwise, false. + private static bool EntityPropertiesMatch(Entity entityA, Entity entityB, HashSet<(object?, object?)> visitedPairs) + { + // Check the entity's DataBlobs for exact information. + List entityADataBlobs = entityA.GetAllDataBlobs(); + List entityBDataBlobs = entityB.GetAllDataBlobs(); + + if (entityADataBlobs.Count != entityBDataBlobs.Count) return false; + + for (int index = 0; index < entityADataBlobs.Count; index++) + { + // Compare each DataBlob to ensure is has exact data. + BaseDataBlob entityADataBlob = entityADataBlobs[index]; + BaseDataBlob entityBDataBlob = entityBDataBlobs[index]; + if (AreEqualInternal(entityADataBlob, entityBDataBlob, visitedPairs)) + continue; + return false; + } + return true; + } } + + }