diff --git a/src/Speckle.Sdk/Serialisation/Utilities/ClosureParser.cs b/src/Speckle.Sdk/Serialisation/Utilities/ClosureParser.cs index 3d33abcb..78dbce11 100644 --- a/src/Speckle.Sdk/Serialisation/Utilities/ClosureParser.cs +++ b/src/Speckle.Sdk/Serialisation/Utilities/ClosureParser.cs @@ -5,7 +5,10 @@ namespace Speckle.Sdk.Serialisation.Utilities; public static class ClosureParser { - public static IReadOnlyList<(string, int)> GetClosures(string json, CancellationToken cancellationToken) + public static IReadOnlyList<(string, int)> GetClosures(string json, CancellationToken cancellationToken) => + GetClosuresPrivate(json, cancellationToken); + + private static List<(string, int)> GetClosuresPrivate(string json, CancellationToken cancellationToken) { try { @@ -31,10 +34,17 @@ public static class ClosureParser return []; } + public static IReadOnlyList<(string, int)> GetClosuresSorted(string json, CancellationToken cancellationToken) + { + var closures = GetClosuresPrivate(json, cancellationToken); + closures.Sort((a, b) => b.Item2.CompareTo(a.Item2)); + return closures; + } + public static IEnumerable GetChildrenIds(string json, CancellationToken cancellationToken) => GetClosures(json, cancellationToken).Select(x => x.Item1); - private static IReadOnlyList<(string, int)> ReadObject(JsonTextReader reader, CancellationToken cancellationToken) + private static List<(string, int)> ReadObject(JsonTextReader reader, CancellationToken cancellationToken) { reader.Read(); while (reader.TokenType != JsonToken.EndObject) diff --git a/src/Speckle.Sdk/Serialisation/V2/Receive/ObjectLoader.cs b/src/Speckle.Sdk/Serialisation/V2/Receive/ObjectLoader.cs index 3ba7c701..0ff0abae 100644 --- a/src/Speckle.Sdk/Serialisation/V2/Receive/ObjectLoader.cs +++ b/src/Speckle.Sdk/Serialisation/V2/Receive/ObjectLoader.cs @@ -45,7 +45,10 @@ public sealed class ObjectLoader( if (rootJson != null) { //assume everything exists as the root is there. - var allChildren = ClosureParser.GetChildrenIds(rootJson, cancellationToken).Select(x => new Id(x)).ToList(); + var allChildren = ClosureParser + .GetClosuresSorted(rootJson, cancellationToken) + .Select(x => new Id(x.Item1)) + .ToList(); //this probably yields away from the Main thread to let host apps update progress //in any case, this fixes a Revit only issue for this situation await Task.Yield(); diff --git a/tests/Speckle.Sdk.Serialization.Tests/ClosureParserTests.cs b/tests/Speckle.Sdk.Serialization.Tests/ClosureParserTests.cs new file mode 100644 index 00000000..7c5ed367 --- /dev/null +++ b/tests/Speckle.Sdk.Serialization.Tests/ClosureParserTests.cs @@ -0,0 +1,188 @@ +using FluentAssertions; +using Speckle.Newtonsoft.Json; +using Speckle.Sdk.Serialisation.Utilities; + +namespace Speckle.Sdk.Serialization.Tests; + +public class ClosureParserTests +{ + [Fact] + public void GetClosures_WithValidJson_ReturnsCorrectClosures() + { + // Arrange + var json = @"{""__closure"": {""id1"": 2, ""id2"": 1, ""id3"": 3}}"; + + // Act + var result = ClosureParser.GetClosures(json, CancellationToken.None); + + // Assert + result.Should().HaveCount(3); + result.Should().Contain((closure) => closure.Item1 == "id1" && closure.Item2 == 2); + result.Should().Contain((closure) => closure.Item1 == "id2" && closure.Item2 == 1); + result.Should().Contain((closure) => closure.Item1 == "id3" && closure.Item2 == 3); + } + + [Fact] + public void GetClosures_WithValidJson_ReturnsUnsorted() + { + // Arrange + var json = @"{""__closure"": {""id1"": 2, ""id2"": 1, ""id3"": 3}}"; + + // Act + var result = ClosureParser.GetClosures(json, CancellationToken.None); + + // Assert + result.Should().HaveCount(3); + result[0].Item2.Should().Be(2); + result[1].Item2.Should().Be(1); + result[2].Item2.Should().Be(3); + } + + [Fact] + public void GetClosures_WithValidJson_ReturnsSortedByDepthDescending() + { + // Arrange + var json = @"{""__closure"": {""id1"": 2, ""id2"": 1, ""id3"": 3}}"; + + // Act + var result = ClosureParser.GetClosuresSorted(json, CancellationToken.None); + + // Assert + result.Should().HaveCount(3); + result[0].Item2.Should().Be(3); + result[1].Item2.Should().Be(2); + result[2].Item2.Should().Be(1); + } + + [Fact] + public void GetChildrenIds_WithValidJson_ReturnsCorrectIds() + { + // Arrange + var json = @"{""__closure"": {""id1"": 2, ""id2"": 1, ""id3"": 3}}"; + + // Act + var result = ClosureParser.GetChildrenIds(json, CancellationToken.None).ToList(); + + // Assert + result.Should().HaveCount(3); + result.Should().Contain("id1"); + result.Should().Contain("id2"); + result.Should().Contain("id3"); + } + + [Fact] + public void GetClosures_WithRandomOrderedClosures_ReturnsSortedByDepthDescending() + { + // Arrange + var random = new Random(42); // Fixed seed for reproducibility + var idDepthPairs = new List<(string id, int depth)> + { + ("id1", 5), + ("id2", 3), + ("id3", 7), + ("id4", 1), + ("id5", 10), + ("id6", 2), + }; + + // Randomize the order + var randomized = idDepthPairs.OrderBy(_ => random.Next()).ToList(); + + // Build JSON with randomized order + using var stringWriter = new StringWriter(); + using var jsonWriter = new JsonTextWriter(stringWriter); + + jsonWriter.WriteStartObject(); + jsonWriter.WritePropertyName("__closure"); + jsonWriter.WriteStartObject(); + + foreach (var pair in randomized) + { + jsonWriter.WritePropertyName(pair.id); + jsonWriter.WriteValue(pair.depth); + } + + jsonWriter.WriteEndObject(); + jsonWriter.WriteEndObject(); + + var json = stringWriter.ToString(); + + // Act + var result = ClosureParser.GetClosuresSorted(json, CancellationToken.None); + + // Assert + result.Should().HaveCount(6); + + // Verify sorting is correct (descending by depth) + for (int i = 0; i < result.Count - 1; i++) + { + result[i].Item2.Should().BeGreaterThanOrEqualTo(result[i + 1].Item2); + } + + // Verify specific order + result[0].Item1.Should().Be("id5"); // depth 10 + result[1].Item1.Should().Be("id3"); // depth 7 + result[2].Item1.Should().Be("id1"); // depth 5 + result[3].Item1.Should().Be("id2"); // depth 3 + result[4].Item1.Should().Be("id6"); // depth 2 + result[5].Item1.Should().Be("id4"); // depth 1 + } + + [Fact] + public void GetClosures_WithEmptyJson_ReturnsEmptyList() + { + // Arrange + var json = "{}"; + + // Act + var result = ClosureParser.GetClosures(json, CancellationToken.None); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void GetClosures_WithInvalidJson_ReturnsEmptyList() + { + // Arrange + var json = "invalid json"; + + // Act + var result = ClosureParser.GetClosures(json, CancellationToken.None); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void GetClosures_WithNullJson_ReturnsEmptyList() + { + // Arrange + string json = null!; + + // Act + var result = ClosureParser.GetClosures(json, CancellationToken.None); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void GetClosures_WithJsonReader_ReturnsCorrectClosures() + { + // Arrange + var json = @"{""id1"": 2, ""id2"": 1, ""id3"": 3}"; + using var stringReader = new StringReader(json); + using var jsonReader = new JsonTextReader(stringReader); + + // Act + jsonReader.Read(); // Move to start object + var result = ClosureParser.GetClosures(jsonReader, CancellationToken.None); + + // Assert + result.Should().HaveCount(3); + result.Should().Contain((closure) => closure.Item1 == "id1" && closure.Item2 == 2); + result.Should().Contain((closure) => closure.Item1 == "id2" && closure.Item2 == 1); + result.Should().Contain((closure) => closure.Item1 == "id3" && closure.Item2 == 3); + } +}