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
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
+}