From aac9e40173174c12cb17b504f5258ca509d0a029 Mon Sep 17 00:00:00 2001 From: Kevin Jump Date: Wed, 22 Oct 2025 11:18:41 +0100 Subject: [PATCH 1/2] add targets to not copy uSync folder into build outputs, (but to still copy in publish) (#834) * add targets to not copy uSync folder into build outputs, (but to still copy in publish) * Update uSync.BackOffice.Targets/buildTransitive/uSync.BackOffice.Targets.targets Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../buildTransitive/uSync.BackOffice.Targets.targets | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 uSync.BackOffice.Targets/buildTransitive/uSync.BackOffice.Targets.targets diff --git a/uSync.BackOffice.Targets/buildTransitive/uSync.BackOffice.Targets.targets b/uSync.BackOffice.Targets/buildTransitive/uSync.BackOffice.Targets.targets new file mode 100644 index 000000000..6eaa91009 --- /dev/null +++ b/uSync.BackOffice.Targets/buildTransitive/uSync.BackOffice.Targets.targets @@ -0,0 +1,8 @@ + + + + Never + Always + + + \ No newline at end of file From 0a88b0ccc832f360fca621fc711f021bb7ce262a Mon Sep 17 00:00:00 2001 From: Kevin Jump Date: Wed, 22 Oct 2025 11:19:12 +0100 Subject: [PATCH 2/2] Impliment property level merging of content blocks. (Exp) (#830) * Impliment property level merging of content blocks. (Exp) * remove an item from target if it's not in source and the target has inhetited values. * Update uSync.Core/Roots/Configs/SyncConfigMergerBase.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update uSync.Core/Extensions/JsonTextExtensions.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update uSync.Core/Roots/Configs/SyncConfigMergerBase.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update uSync.Core/Roots/Configs/SyncConfigMergerBase.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * [AI Generated - Tests] Add some json merge tests on the base class methods . tests --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- uSync.Core/Extensions/JsonTextExtensions.cs | 11 + .../Roots/Configs/BlockGridConfigMerger.cs | 12 +- .../Roots/Configs/SyncConfigMergerBase.cs | 157 +++- uSync.Tests/Extensions/JsonMergeTests.cs | 756 ++++++++++++++++++ 4 files changed, 920 insertions(+), 16 deletions(-) create mode 100644 uSync.Tests/Extensions/JsonMergeTests.cs diff --git a/uSync.Core/Extensions/JsonTextExtensions.cs b/uSync.Core/Extensions/JsonTextExtensions.cs index 181b288f0..f2f10b437 100644 --- a/uSync.Core/Extensions/JsonTextExtensions.cs +++ b/uSync.Core/Extensions/JsonTextExtensions.cs @@ -202,6 +202,17 @@ public static bool TryConvertToJsonObject(this object value, [MaybeNullWhen(fals public static JsonObject? ConvertToJsonObject(this object value) => value.TryConvertToJsonObject(out JsonObject? result) ? result : default; + public static void AddOrRemoveIfNull(this JsonObject? jsonObject, string property, T? value) + where T : JsonNode + { + if (jsonObject == null) + return; + if (value is not null) + jsonObject[property] = value; + else + jsonObject.Remove(property); + } + #endregion #region JsonArray diff --git a/uSync.Core/Roots/Configs/BlockGridConfigMerger.cs b/uSync.Core/Roots/Configs/BlockGridConfigMerger.cs index 43dc9035f..671da742f 100644 --- a/uSync.Core/Roots/Configs/BlockGridConfigMerger.cs +++ b/uSync.Core/Roots/Configs/BlockGridConfigMerger.cs @@ -1,5 +1,4 @@ -using System.Collections.Immutable; -using System.Text.Json.Nodes; +using System.Text.Json.Nodes; using Umbraco.Cms.Core; @@ -28,12 +27,13 @@ public virtual object GetMergedConfig(string root, string target) if (rootConfig is null) return target; if (targetConfig is null) return root; - // merge groups - targetConfig["blockGroups"] = MergeGroups(rootConfig, targetConfig); // merge blocks targetConfig["blocks"] = GetMergedBlocks(rootConfig, targetConfig); + // merge groups + targetConfig.AddOrRemoveIfNull("blockGroups", MergeGroups(rootConfig, targetConfig)); + return targetConfig; } @@ -60,7 +60,7 @@ public virtual object GetDifferenceConfig(string root, string target) rootConfig.TryGetPropertyAsArray("blockGroups", out var rootGroups); targetConfig.TryGetPropertyAsArray("blockGroups", out var targetGroups); - var groupDiffrences = GetJsonArrayDifferences(rootGroups, targetGroups, "name", "name"); + var groupDiffrences = GetJsonArrayDifferences(rootGroups, targetGroups, "key", "name"); return groupDiffrences?.Count > 0 ? groupDiffrences : null; } @@ -68,7 +68,7 @@ public virtual object GetDifferenceConfig(string root, string target) { rootConfig.TryGetPropertyAsArray("blockGroups", out var rootGroups); targetConfig.TryGetPropertyAsArray("blockGroups", out var targetGroups); - var mergedGroups = MergeJsonArrays(rootGroups, targetGroups, "name", "name"); + var mergedGroups = MergeJsonArrays(rootGroups, targetGroups, "key", "name"); return mergedGroups?.Count > 0 ? mergedGroups : null; } diff --git a/uSync.Core/Roots/Configs/SyncConfigMergerBase.cs b/uSync.Core/Roots/Configs/SyncConfigMergerBase.cs index 5d38dd97e..46969db32 100644 --- a/uSync.Core/Roots/Configs/SyncConfigMergerBase.cs +++ b/uSync.Core/Roots/Configs/SyncConfigMergerBase.cs @@ -1,5 +1,7 @@ using Json.More; + +using System.Text.Json; using System.Text.Json.Nodes; using Umbraco.Extensions; @@ -11,6 +13,14 @@ namespace uSync.Core.Roots.Configs; internal class SyncConfigMergerBase { protected static string _removedLabel = "uSync:Removed in child site."; + protected static string _inheritedValue = "uSync:Inherited from root."; + + protected static Dictionary _knownArrayKeys = new() { + { "blocks", (key: "contentElementTypeKey", label: "label") }, + { "blockGroups", (key: "key", label: "name") }, + { "areas", (key: "key", label: "alias") }, + { "specifiedAllowance", (key: "elementTypeKey", label: "removed") } + }; protected TConfig? TryGetConfiguration(string value) { @@ -47,7 +57,7 @@ protected static TObject[] MergeObjects(TObject[] rootObject, TOb return [.. mergedObject]; } - protected TObject[] GetObjectDifferences(TObject[]? rootObject, TObject[]? targetObject, Func keySelector, Action setMarker) + protected static TObject[] GetObjectDifferences(TObject[]? rootObject, TObject[]? targetObject, Func keySelector, Action setMarker) { var rootObjectKeys = rootObject?.Select(keySelector) ?? []; var targetObjectKeys = targetObject?.Select(keySelector) ?? []; @@ -76,16 +86,29 @@ protected TObject[] GetObjectDifferences(TObject[]? rootObject, T var sourceItems = sourceArray? .Select(x => x as JsonObject)? .WhereNotNull() - .ToDictionary(k => k.TryGetPropertyAsObject(key, out var sourceKey) ? sourceKey.ToString() : "", v => v) ?? []; + .ToDictionary(k => k.TryGetPropertyAsObject(key, out var sourceKey) ? sourceKey.GetValueAsString(key) ?? sourceKey.ToString() : "", v => v) ?? []; var targetItems = targetArray? .Select(x => x as JsonObject)? .WhereNotNull() - .ToDictionary(k => k.TryGetPropertyAsObject(key, out var targetKey) ? targetKey.ToString() : "", v => v) ?? []; + .ToDictionary(k => k.TryGetPropertyAsObject(key, out var targetKey) ? targetKey.GetValueAsString(key) ?? targetKey.ToString() : "", v => v) ?? []; // things that are only in the target. var targetOnly = targetItems.Where(x => sourceItems.ContainsKey(x.Key) is false).Select(x => x.Value).ToList() ?? []; + foreach (var block in targetItems) + { + var sourceItem = sourceItems.GetValueOrDefault(block.Key); + if (sourceItem is null) continue; + + if (block.Value.IsJsonEqual(sourceItem) is false) + { + // the values are different, so we go property by property to see if we can merge them. + var target = GetJsonPropertyDifferences(sourceItem, block.Value, key); + targetOnly.Add(target); + } + } + // keys that are only in the source have been removed from the child, we need to mark them as removed. foreach (var removedItem in sourceItems.Where(x => targetItems.ContainsKey(x.Key) is false)) { @@ -96,13 +119,63 @@ protected TObject[] GetObjectDifferences(TObject[]? rootObject, T return targetOnly.ToJsonArray(); } + private static JsonObject GetJsonPropertyDifferences(JsonObject sourceObject, JsonObject targetObject, string propertyKey) + { + foreach (var property in sourceObject) + { + // if this is the key property or it's value is null skip it. + if (property.Key == propertyKey || property.Value is null) continue; + + if (targetObject.TryGetPropertyValue(property.Key, out var targetValue) is false || targetValue is null) + { + // value isn't in target. inherit. + targetObject[property.Key] = JsonValue.Create(_inheritedValue); + continue; + } + + if (property.Value.IsJsonEqual(targetValue) is false) + { + // target is an update so we keep this value. + // unless its an array, and then we have to merge deeper. + switch (targetValue.GetValueKind()) + { + case JsonValueKind.Array: + var sourceArray = property.Value as JsonArray; + var targetArray = targetValue as JsonArray; + + // this assumes we know what they key should be based on our array of well known array keys. + // if the array is new or generic we fall back to 'key'; + var (arrayKey, arrayLabel) = _knownArrayKeys.GetValueOrDefault(property.Key, (key: "key", label: "label")); + targetObject[property.Key] = GetJsonArrayDifferences(sourceArray, targetArray, arrayKey, arrayLabel); + break; + case JsonValueKind.Object: + // i am not sure we ever hit this in the block config, but it's here should the block or json store + // an object in it. - the key is redundant, at this point, because a single object (not an array) + // wouldn't have a key. + if (property.Value is JsonObject sourceObj && targetValue is JsonObject targetObj) + { + targetObject[property.Key] = GetJsonPropertyDifferences(sourceObj, targetObj, string.Empty); + } + break; + } + } + else + { + // source wins. + targetObject[property.Key] = JsonValue.Create(_inheritedValue); + } + } + + return targetObject; + } + protected static JsonArray? MergeJsonArrays(JsonArray? sourceArray, JsonArray? targetArray, string key, string removeProperty) { // no source, we return target if (sourceArray is null) return targetArray; // no target we return source (we have to clone it). - if (targetArray is null) return sourceArray.DeepClone().AsArray(); + if (targetArray is null) return sourceArray.DeepClone() as JsonArray; // merge them. foreach (var sourceItem in sourceArray) @@ -110,19 +183,28 @@ protected TObject[] GetObjectDifferences(TObject[]? rootObject, T if (sourceItem is not JsonObject sourceObject) continue; if (sourceObject.TryGetPropertyAsObject(key, out var sourceKey) is false) continue; - var targetObject = targetArray.FirstOrDefault( - x => (x as JsonObject)?.TryGetPropertyAsObject(key, out var targetKey) == true && targetKey.GetValueAsString(key) == sourceKey.GetValueAsString(key)) - as JsonObject; + var targetObject = targetArray + .Select(x => x as JsonObject) + .WhereNotNull() + .FirstOrDefault(x => x?.TryGetPropertyAsObject(key, out var targetKey) == true && targetKey.GetValueAsString(key) == sourceKey.GetValueAsString(key)); if (targetObject is null) { var clonedItem = sourceObject.SerializeJsonString().DeserializeJson(); targetArray.Add(clonedItem); } + else + { + if (sourceItem.IsJsonEqual(targetObject) is false) + { + // they are different we need to merge the properties. + targetObject = MergeJsonProperties(sourceObject, targetObject, key); + } + } } List removals = []; - for(int i = 0; i < targetArray.Count; i++) + for (int i = 0; i < targetArray.Count; i++) { if (targetArray[i] is not JsonObject targetObject) continue; if (targetObject.ContainsKey(removeProperty) is false) continue; @@ -131,10 +213,19 @@ protected TObject[] GetObjectDifferences(TObject[]? rootObject, T { // we can't remove it while iterating, so add to a list. removals.Add(i); + continue; + } + + // if the item has been removed from source, but the target has + // values inherited from source we need to now remove it? + var targetJson = targetObject.SerializeJsonString(false); + if (targetJson.Contains(_inheritedValue) is true) + { + removals.Add(i); } } - - foreach(var index in removals.OrderDescending()) + + foreach (var index in removals.OrderDescending()) { targetArray.RemoveAt(index); } @@ -142,4 +233,50 @@ protected TObject[] GetObjectDifferences(TObject[]? rootObject, T return targetArray; } + private static JsonObject MergeJsonProperties(JsonObject sourceObject, JsonObject targetObject, string propertyKey) + { + var targetItems = targetObject.ToDictionary( + k => k.Key, v => v.Value); + + foreach (var property in targetItems) + { + if (property.Key == propertyKey || property.Value is null) continue; + + switch (property.Value.GetValueKind()) + { + case JsonValueKind.Array: + var sourceArray = sourceObject.GetPropertyAsArray(property.Key); + var targetArray = targetObject.GetPropertyAsArray(property.Key); + var (arrayKey, arrayLabel) = _knownArrayKeys.GetValueOrDefault(property.Key, (key: "key", label: "label")); + targetObject[property.Key] = MergeJsonArrays(sourceArray, targetArray, arrayKey, arrayLabel); + continue; + case JsonValueKind.Object: + var sourcePropertyObject = sourceObject.GetPropertyAsObject(property.Key); + if (sourcePropertyObject is not null && property.Value is JsonObject targetPropertyObject) + { + targetObject[property.Key] = MergeJsonProperties(sourcePropertyObject, targetPropertyObject, string.Empty); + } + break; + } + + if (property.Value.ToString() == _inheritedValue) + { + if (sourceObject.TryGetPropertyValue(property.Key, out var sourceValue) is false) + { + // is this an error? + // It means the value doesn't exist in the source, but at some point it has, + // because we have inherited it from the source. If it's a case of the values + // that are not set just not making it to the JSON then we can remove the + // property from target, and that is the same as it inheriting it from the source. + targetObject.Remove(property.Key); + continue; + } + + targetObject[property.Key] = sourceValue?.DeepClone(); + } + } + + return targetObject; + } + } diff --git a/uSync.Tests/Extensions/JsonMergeTests.cs b/uSync.Tests/Extensions/JsonMergeTests.cs new file mode 100644 index 000000000..b140d0695 --- /dev/null +++ b/uSync.Tests/Extensions/JsonMergeTests.cs @@ -0,0 +1,756 @@ +#nullable enable +using System; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; + +using NUnit.Framework; + +using Umbraco.Extensions; + +using uSync.Core.Extensions; + +namespace uSync.Tests.Extensions; + +[TestFixture] +internal class JsonMergeTests +{ + private static readonly Type SyncConfigMergerBaseType = Type.GetType("uSync.Core.Roots.Configs.SyncConfigMergerBase, uSync.Core")!; + + // Test helper class for MergeObjects tests + private class TestObject + { + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public bool IsRemoved { get; set; } + + public override bool Equals(object? obj) => obj is TestObject other && Id == other.Id && Name == other.Name; + public override int GetHashCode() => HashCode.Combine(Id, Name); + } + + #region MergeObjects Tests + + [Test] + public void MergeObjects_WithValidRootAndTargetObjects_MergesCorrectly() + { + // Arrange + var rootObjects = new[] + { + new TestObject { Id = "1", Name = "Root1" }, + new TestObject { Id = "2", Name = "Root2" }, + new TestObject { Id = "3", Name = "Root3" } + }; + + var targetObjects = new[] + { + new TestObject { Id = "2", Name = "Target2" }, // Override + new TestObject { Id = "4", Name = "Target4" } // New + }; + + var method = GetGenericStaticMethod("MergeObjects", typeof(TestObject), typeof(string)); + var keySelector = new Func(x => x.Id); + var predicate = new Predicate(x => x.IsRemoved); + + // Act + var result = (TestObject[])method.Invoke(null, [rootObjects, targetObjects, keySelector, predicate])!; + + // Assert + Assert.That(result, Has.Length.EqualTo(4)); + Assert.That(result.Any(x => x.Id == "1" && x.Name == "Root1"), Is.True); + Assert.That(result.Any(x => x.Id == "2" && x.Name == "Target2"), Is.True); + Assert.That(result.Any(x => x.Id == "3" && x.Name == "Root3"), Is.True); + Assert.That(result.Any(x => x.Id == "4" && x.Name == "Target4"), Is.True); + } + + [Test] + public void MergeObjects_WithNullTargetObjects_ReturnsRootObjectsOnly() + { + // Arrange + var rootObjects = new[] + { + new TestObject { Id = "1", Name = "Root1" }, + new TestObject { Id = "2", Name = "Root2" } + }; + + var method = GetGenericStaticMethod("MergeObjects", typeof(TestObject), typeof(string)); + var keySelector = new Func(x => x.Id); + var predicate = new Predicate(x => x.IsRemoved); + + // Act + var result = (TestObject[])method.Invoke(null, [rootObjects, null, keySelector, predicate])!; + + // Assert + Assert.That(result, Has.Length.EqualTo(2)); + Assert.That(result.Any(x => x.Id == "1"), Is.True); + Assert.That(result.Any(x => x.Id == "2"), Is.True); + } + + [Test] + public void MergeObjects_WithRemovedItems_FiltersOutRemovedItems() + { + // Arrange + var rootObjects = new[] + { + new TestObject { Id = "1", Name = "Root1" }, + new TestObject { Id = "2", Name = "Root2" } + }; + + var targetObjects = new[] + { + new TestObject { Id = "3", Name = "Target3", IsRemoved = true }, + new TestObject { Id = "4", Name = "Target4", IsRemoved = false } + }; + + var method = GetGenericStaticMethod("MergeObjects", typeof(TestObject), typeof(string)); + var keySelector = new Func(x => x.Id); + var predicate = new Predicate(x => x.IsRemoved); + + // Act + var result = (TestObject[])method.Invoke(null, [rootObjects, targetObjects, keySelector, predicate])!; + + // Assert + Assert.That(result, Has.Length.EqualTo(3)); + Assert.That(result.Any(x => x.Id == "3"), Is.False); // Removed item should not be present + Assert.That(result.Any(x => x.Id == "4"), Is.True); + } + + [Test] + public void MergeObjects_WithStringKeysContainingRemovedLabel_HandlesCorrectly() + { + // Arrange + var rootObjects = new[] + { + new TestObject { Id = "1", Name = "Root1" }, + new TestObject { Id = "2", Name = "Root2" } + }; + + var targetObjects = new[] + { + new TestObject { Id = "uSync:Removed in child site.:1", Name = "Target1" }, // Contains removal label + new TestObject { Id = "3", Name = "Target3" } + }; + + var method = GetGenericStaticMethod("MergeObjects", typeof(TestObject), typeof(string)); + var keySelector = new Func(x => x.Id); + var predicate = new Predicate(x => x.IsRemoved); + + // Act + var result = (TestObject[])method.Invoke(null, [rootObjects, targetObjects, keySelector, predicate])!; + + // Assert + // When the removal label is stripped from "uSync:Removed in child site.:1", it becomes "1" + // This matches the root object with Id "1", so the root object is excluded from the merge + // The final result should have: Target1 (with full removal label), Root2, Target3 + Assert.That(result, Has.Length.EqualTo(3)); + + // Should include the target item with the full removal label + Assert.That(result.Any(x => x.Id.Contains("uSync:Removed in child site.:1")), Is.True); + // Should include Root2 since it has no matching target + Assert.That(result.Any(x => x.Id == "2" && x.Name == "Root2"), Is.True); + // Should include Target3 + Assert.That(result.Any(x => x.Id == "3" && x.Name == "Target3"), Is.True); + // Should NOT include Root1 because it was excluded due to key matching after label stripping + Assert.That(result.Any(x => x.Id == "1" && x.Name == "Root1"), Is.False); + } + + #endregion + + #region GetObjectDifferences Tests + + [Test] + public void GetObjectDifferences_WithRemovedItems_MarksThemAsRemoved() + { + // Arrange + var rootObjects = new[] + { + new TestObject { Id = "1", Name = "Root1" }, + new TestObject { Id = "2", Name = "Root2" }, + new TestObject { Id = "3", Name = "Root3" } + }; + + var targetObjects = new[] + { + new TestObject { Id = "1", Name = "Target1" }, // Exists in both + new TestObject { Id = "4", Name = "Target4" } // Only in target + }; + + var method = GetGenericStaticMethod("GetObjectDifferences", typeof(TestObject), typeof(string)); + var keySelector = new Func(x => x.Id); + var setMarker = new Action((obj, marker) => obj.Name = $"{marker}:{obj.Name}"); + + // Act + var result = (TestObject[])method.Invoke(null, [rootObjects, targetObjects, keySelector, setMarker])!; + + // Assert + Assert.That(result, Has.Length.EqualTo(3)); + + // Should contain items only in target + Assert.That(result.Any(x => x.Id == "4" && x.Name == "Target4"), Is.True); + + // Should contain removed items from root marked with removal label + var removedItems = result.Where(x => x.Name.StartsWith("uSync:Removed in child site.:")).ToArray(); + Assert.That(removedItems, Has.Length.EqualTo(2)); + Assert.That(removedItems.Any(x => x.Id == "2"), Is.True); + Assert.That(removedItems.Any(x => x.Id == "3"), Is.True); + } + + [Test] + public void GetObjectDifferences_WithNullArrays_HandlesGracefully() + { + // Arrange + var method = GetGenericStaticMethod("GetObjectDifferences", typeof(TestObject), typeof(string)); + var keySelector = new Func(x => x.Id); + var setMarker = new Action((obj, marker) => obj.Name = $"{marker}:{obj.Name}"); + + // Act & Assert - null root objects + var result1 = (TestObject[])method.Invoke(null, [null, new TestObject[0], keySelector, setMarker])!; + Assert.That(result1, Has.Length.EqualTo(0)); + + // Act & Assert - null target objects + var result2 = (TestObject[])method.Invoke(null, [new TestObject[0], null, keySelector, setMarker])!; + Assert.That(result2, Has.Length.EqualTo(0)); + } + + #endregion + + #region GetJsonArrayDifferences Tests + + [Test] + public void GetJsonArrayDifferences_WithBasicArrays_ReturnsCorrectDifferences() + { + // Arrange + var sourceArray = JsonSerializer.Deserialize(""" + [ + { "key": "item1", "value": "source1", "label": "Source Item 1" }, + { "key": "item2", "value": "source2", "label": "Source Item 2" } + ] + """); + + var targetArray = JsonSerializer.Deserialize(""" + [ + { "key": "item1", "value": "target1", "label": "Target Item 1" }, + { "key": "item3", "value": "target3", "label": "Target Item 3" } + ] + """); + + var method = GetStaticMethod("GetJsonArrayDifferences"); + + // Act + var result = (JsonArray)method.Invoke(null, [sourceArray, targetArray, "key", "label"])!; + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Count, Is.EqualTo(3)); + + // Should have the modified item1 + var item1 = result.FirstOrDefault(x => x?["key"]?.ToString() == "item1"); + Assert.That(item1, Is.Not.Null); + Assert.That(item1!["value"]?.ToString(), Is.EqualTo("target1")); + + // Should have the new item3 + var item3 = result.FirstOrDefault(x => x?["key"]?.ToString() == "item3"); + Assert.That(item3, Is.Not.Null); + + // Should have the removed item2 marked as removed + var item2 = result.FirstOrDefault(x => x?["key"]?.ToString() == "item2"); + Assert.That(item2, Is.Not.Null); + Assert.That(item2!["label"]?.ToString(), Is.EqualTo("uSync:Removed in child site.")); + } + + [Test] + public void GetJsonArrayDifferences_WithNullTargetArray_ReturnsEmptyArray() + { + // Arrange + var sourceArray = JsonSerializer.Deserialize(""" + [ + { "key": "item1", "value": "source1" } + ] + """); + + var method = GetStaticMethod("GetJsonArrayDifferences"); + + // Act + var result = (JsonArray)method.Invoke(null, [sourceArray, null, "key", "label"])!; + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Count, Is.EqualTo(0)); + } + + [Test] + public void GetJsonArrayDifferences_WithIdenticalArrays_ReturnsInheritedValues() + { + // Arrange + var sourceArray = JsonSerializer.Deserialize(""" + [ + { "key": "item1", "value": "same", "label": "Same Item" } + ] + """); + + var targetArray = JsonSerializer.Deserialize(""" + [ + { "key": "item1", "value": "same", "label": "Same Item" } + ] + """); + + var method = GetStaticMethod("GetJsonArrayDifferences"); + + // Act + var result = (JsonArray)method.Invoke(null, [sourceArray, targetArray, "key", "label"])!; + + // Assert + Assert.That(result, Is.Not.Null); + // when everthing is identical nothing is returned because the root is right. + Assert.That(result.Count, Is.EqualTo(0)); + } + + [Test] + public void GetJsonArrayDifferences_WithNestedArrayProperties_HandlesCorrectly() + { + // Arrange + var sourceArray = JsonSerializer.Deserialize(""" + [ + { + "key": "item1", + "value": "source1", + "nestedArray": [ + { "subkey": "sub1", "subvalue": "sourceSubValue1" } + ] + } + ] + """); + + var targetArray = JsonSerializer.Deserialize(""" + [ + { + "key": "item1", + "value": "target1", + "nestedArray": [ + { "subkey": "sub1", "subvalue": "targetSubValue1" } + ] + } + ] + """); + + var method = GetStaticMethod("GetJsonArrayDifferences"); + + // Act + var result = (JsonArray)method.Invoke(null, [sourceArray, targetArray, "key", "label"])!; + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Count, Is.EqualTo(1)); + + var item = result[0]; + Assert.That(item!["key"]?.ToString(), Is.EqualTo("item1")); + Assert.That(item["value"]?.ToString(), Is.EqualTo("target1")); + + // Should handle nested array differences + var nestedArray = item["nestedArray"] as JsonArray; + Assert.That(nestedArray, Is.Not.Null); + } + + #endregion + + #region MergeJsonArrays Tests + + [Test] + public void MergeJsonArrays_WithBothArrays_MergesCorrectly() + { + // Arrange + var sourceArray = JsonSerializer.Deserialize(""" + [ + { "key": "item1", "value": "source1", "name": "Source Item 1" }, + { "key": "item2", "value": "source2", "name": "Source Item 2" } + ] + """); + + var targetArray = JsonSerializer.Deserialize(""" + [ + { "key": "item1", "value": "target1", "name": "Target Item 1" }, + { "key": "item3", "value": "target3", "name": "Target Item 3" } + ] + """); + + var method = GetStaticMethod("MergeJsonArrays"); + + // Act + var result = (JsonArray)method.Invoke(null, [sourceArray, targetArray, "key", "name"])!; + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Count, Is.EqualTo(3)); + + // Should have merged item1 (target wins) + var item1 = result.FirstOrDefault(x => x?["key"]?.ToString() == "item1"); + Assert.That(item1, Is.Not.Null); + Assert.That(item1!["value"]?.ToString(), Is.EqualTo("target1")); + + // Should have source item2 + var item2 = result.FirstOrDefault(x => x?["key"]?.ToString() == "item2"); + Assert.That(item2, Is.Not.Null); + Assert.That(item2!["value"]?.ToString(), Is.EqualTo("source2")); + + // Should have target item3 + var item3 = result.FirstOrDefault(x => x?["key"]?.ToString() == "item3"); + Assert.That(item3, Is.Not.Null); + Assert.That(item3!["value"]?.ToString(), Is.EqualTo("target3")); + } + + [Test] + public void MergeJsonArrays_WithNullSource_ReturnsTarget() + { + // Arrange + var targetArray = JsonSerializer.Deserialize(""" + [ + { "key": "item1", "value": "target1" } + ] + """); + + var method = GetStaticMethod("MergeJsonArrays"); + + // Act + var result = (JsonArray)method.Invoke(null, [null, targetArray, "key", "name"])!; + + // Assert + Assert.That(result, Is.EqualTo(targetArray)); + } + + [Test] + public void MergeJsonArrays_WithNullTarget_ReturnsClonedSource() + { + // Arrange + var sourceArray = JsonSerializer.Deserialize(""" + [ + { "key": "item1", "value": "source1" } + ] + """); + + var method = GetStaticMethod("MergeJsonArrays"); + + // Act + var result = (JsonArray)method.Invoke(null, [sourceArray, null, "key", "name"])!; + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Count, Is.EqualTo(1)); + Assert.That(result, Is.Not.SameAs(sourceArray)); // Should be cloned + Assert.That(result[0]!["key"]?.ToString(), Is.EqualTo("item1")); + } + + [Test] + public void MergeJsonArrays_WithRemovedItems_FiltersOutRemovedItems() + { + // Arrange + var sourceArray = JsonSerializer.Deserialize(""" + [ + { "key": "item1", "value": "source1" } + ] + """); + + var targetArray = JsonSerializer.Deserialize(""" + [ + { "key": "item2", "value": "target2", "name": "uSync:Removed in child site.:Item2" }, + { "key": "item3", "value": "uSync:Inherited from root.", "name": "Item3" } + ] + """); + + var method = GetStaticMethod("MergeJsonArrays"); + + // Act + var result = (JsonArray)method.Invoke(null, [sourceArray, targetArray, "key", "name"])!; + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Count, Is.EqualTo(1)); // Only item1 should remain + Assert.That(result[0]!["key"]?.ToString(), Is.EqualTo("item1")); + } + + #endregion + + #region Private Method Tests via Reflection + + [Test] + public void GetJsonPropertyDifferences_WithMissingPropertiesInTarget_AddsInheritedMarkers() + { + // Arrange + var sourceObject = JsonSerializer.Deserialize(""" + { + "key": "item1", + "prop1": "sourceProp1", + "prop2": "sourceProp2" + } + """)!; + + var targetObject = JsonSerializer.Deserialize(""" + { + "key": "item1", + "prop1": "targetProp1" + } + """)!; + + var method = GetPrivateStaticMethod("GetJsonPropertyDifferences"); + + // Act + var result = (JsonObject)method.Invoke(null, [sourceObject, targetObject, "key"])!; + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result["key"]?.ToString(), Is.EqualTo("item1")); + Assert.That(result["prop1"]?.ToString(), Is.EqualTo("targetProp1")); // Target value wins + Assert.That(result["prop2"]?.ToString(), Is.EqualTo("uSync:Inherited from root.")); // Missing property inherits + } + + [Test] + public void GetJsonPropertyDifferences_WithIdenticalProperties_AddsInheritedMarkers() + { + // Arrange + var sourceObject = JsonSerializer.Deserialize(""" + { + "key": "item1", + "prop1": "sameProp1", + "prop2": "sameProp2" + } + """)!; + + var targetObject = JsonSerializer.Deserialize(""" + { + "key": "item1", + "prop1": "sameProp1", + "prop2": "sameProp2" + } + """)!; + + var method = GetPrivateStaticMethod("GetJsonPropertyDifferences"); + + // Act + var result = (JsonObject)method.Invoke(null, [sourceObject, targetObject, "key"])!; + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result["key"]?.ToString(), Is.EqualTo("item1")); + Assert.That(result["prop1"]?.ToString(), Is.EqualTo("uSync:Inherited from root.")); // Identical values inherit + Assert.That(result["prop2"]?.ToString(), Is.EqualTo("uSync:Inherited from root.")); + } + + [Test] + public void MergeJsonProperties_WithInheritedValues_ReplacesWithSourceValues() + { + // Arrange + var sourceObject = JsonSerializer.Deserialize(""" + { + "key": "item1", + "prop1": "sourceProp1", + "prop2": "sourceProp2" + } + """)!; + + var targetObject = JsonSerializer.Deserialize(""" + { + "key": "item1", + "prop1": "uSync:Inherited from root.", + "prop2": "targetProp2", + "prop3": "uSync:Inherited from root." + } + """)!; + + var method = GetPrivateStaticMethod("MergeJsonProperties"); + + // Act + var result = (JsonObject)method.Invoke(null, [sourceObject, targetObject, "key"])!; + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result["key"]?.ToString(), Is.EqualTo("item1")); + Assert.That(result["prop1"]?.ToString(), Is.EqualTo("sourceProp1")); // Inherited value replaced with source + Assert.That(result["prop2"]?.ToString(), Is.EqualTo("targetProp2")); // Target value preserved + Assert.That(result.ContainsKey("prop3"), Is.False); // Inherited property without source should be removed + } + + #endregion + + #region Helper Methods + + private static MethodInfo GetStaticMethod(string methodName) + { + var method = SyncConfigMergerBaseType.GetMethod(methodName, + BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); + + if (method == null) + { + throw new ArgumentException($"Method '{methodName}' not found in SyncConfigMergerBase"); + } + + return method; + } + + private static MethodInfo GetGenericStaticMethod(string methodName, params Type[] genericTypes) + { + var methods = SyncConfigMergerBaseType.GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public) + .Where(m => m.Name == methodName && m.IsGenericMethodDefinition); + + var method = methods.FirstOrDefault(); + if (method == null) + { + throw new ArgumentException($"Generic method '{methodName}' not found in SyncConfigMergerBase"); + } + + // Create the generic method with specific types + return method.MakeGenericMethod(genericTypes); + } + + private static MethodInfo GetPrivateStaticMethod(string methodName) + { + var method = SyncConfigMergerBaseType.GetMethod(methodName, + BindingFlags.Static | BindingFlags.NonPublic); + + if (method == null) + { + throw new ArgumentException($"Private method '{methodName}' not found in SyncConfigMergerBase"); + } + + return method; + } + + #endregion + + #region Edge Cases and Error Handling + + [Test] + public void MergeObjects_WithEmptyArrays_HandlesCorrectly() + { + // Arrange + var method = GetGenericStaticMethod("MergeObjects", typeof(TestObject), typeof(string)); + var keySelector = new Func(x => x.Id); + var predicate = new Predicate(x => x.IsRemoved); + + // Act + var result = (TestObject[])method.Invoke(null, [Array.Empty(), Array.Empty(), keySelector, predicate])!; + + // Assert + Assert.That(result, Has.Length.EqualTo(0)); + } + + [Test] + public void GetJsonArrayDifferences_WithMalformedJson_HandlesGracefully() + { + // Arrange + var sourceArray = JsonSerializer.Deserialize(""" + [ + { "key": "item1", "value": "source1" }, + { "wrongstructure": "invalid" } + ] + """); + + var targetArray = JsonSerializer.Deserialize(""" + [ + { "key": "item1", "value": "target1" } + ] + """); + + var method = GetStaticMethod("GetJsonArrayDifferences"); + + // Act & Assert (should not throw) + Assert.DoesNotThrow(() => + { + var result = (JsonArray)method.Invoke(null, [sourceArray, targetArray, "key", "label"])!; + Assert.That(result, Is.Not.Null); + }); + } + + [Test] + public void MergeJsonArrays_WithComplexNestedStructures_HandlesCorrectly() + { + // Arrange + var sourceArray = JsonSerializer.Deserialize(""" + [ + { + "key": "item1", + "value": "source1", + "nested": { + "prop1": "sourceProp1", + "prop2": "sourceProp2" + } + } + ] + """); + + var targetArray = JsonSerializer.Deserialize(""" + [ + { + "key": "item1", + "value": "target1", + "nested": { + "prop1": "targetProp1", + "prop3": "targetProp3" + } + } + ] + """); + + var method = GetStaticMethod("MergeJsonArrays"); + + // Act + var result = (JsonArray)method.Invoke(null, [sourceArray, targetArray, "key", "name"])!; + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Count, Is.EqualTo(1)); + + var item = result[0]; + Assert.That(item!["key"]?.ToString(), Is.EqualTo("item1")); + Assert.That(item["value"]?.ToString(), Is.EqualTo("target1")); + + // Nested object should be handled appropriately + var nested = item["nested"]; + Assert.That(nested, Is.Not.Null); + } + + [Test] + public void GetJsonArrayDifferences_WithEmptyArrays_ReturnsEmptyResult() + { + // Arrange + var sourceArray = JsonSerializer.Deserialize("[]"); + var targetArray = JsonSerializer.Deserialize("[]"); + + var method = GetStaticMethod("GetJsonArrayDifferences"); + + // Act + var result = (JsonArray)method.Invoke(null, [sourceArray, targetArray, "key", "label"])!; + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Count, Is.EqualTo(0)); + } + + [Test] + public void MergeJsonArrays_WithNonObjectElements_HandlesGracefully() + { + // Arrange - Array containing non-object elements + var sourceArray = JsonSerializer.Deserialize(""" + [ + "stringValue", + { "key": "item1", "value": "source1" }, + 123 + ] + """); + + var targetArray = JsonSerializer.Deserialize(""" + [ + { "key": "item1", "value": "target1" } + ] + """); + + var method = GetStaticMethod("MergeJsonArrays"); + + // Act & Assert (should not throw) + Assert.DoesNotThrow(() => + { + var result = (JsonArray)method.Invoke(null, [sourceArray, targetArray, "key", "name"])!; + Assert.That(result, Is.Not.Null); + }); + } + + #endregion +}