diff --git a/TUnit.Assertions.Tests/JsonElementAssertionTests.cs b/TUnit.Assertions.Tests/JsonElementAssertionTests.cs new file mode 100644 index 0000000000..d75275e8c7 --- /dev/null +++ b/TUnit.Assertions.Tests/JsonElementAssertionTests.cs @@ -0,0 +1,189 @@ +using System.Text.Json; +using TUnit.Assertions.Extensions; + +namespace TUnit.Assertions.Tests; + +public class JsonElementAssertionTests +{ + [Test] + public async Task IsObject_WithObject_Passes() + { + using var doc = JsonDocument.Parse("{\"name\":\"test\"}"); + await Assert.That(doc.RootElement).IsObject(); + } + + [Test] + public async Task IsObject_WithArray_Fails() + { + using var doc = JsonDocument.Parse("[1,2,3]"); + await Assert.ThrowsAsync( + async () => await Assert.That(doc.RootElement).IsObject()); + } + + [Test] + public async Task IsArray_WithArray_Passes() + { + using var doc = JsonDocument.Parse("[1,2,3]"); + await Assert.That(doc.RootElement).IsArray(); + } + + [Test] + public async Task IsArray_WithNonArray_Fails() + { + using var doc = JsonDocument.Parse("{\"key\":\"value\"}"); + await Assert.ThrowsAsync( + async () => await Assert.That(doc.RootElement).IsArray()); + } + + [Test] + public async Task IsString_WithString_Passes() + { + using var doc = JsonDocument.Parse("\"hello\""); + await Assert.That(doc.RootElement).IsString(); + } + + [Test] + public async Task IsString_WithNonString_Fails() + { + using var doc = JsonDocument.Parse("42"); + await Assert.ThrowsAsync( + async () => await Assert.That(doc.RootElement).IsString()); + } + + [Test] + public async Task IsNumber_WithNumber_Passes() + { + using var doc = JsonDocument.Parse("42"); + await Assert.That(doc.RootElement).IsNumber(); + } + + [Test] + public async Task IsNumber_WithNonNumber_Fails() + { + using var doc = JsonDocument.Parse("\"text\""); + await Assert.ThrowsAsync( + async () => await Assert.That(doc.RootElement).IsNumber()); + } + + [Test] + public async Task IsBoolean_WithTrue_Passes() + { + using var doc = JsonDocument.Parse("true"); + await Assert.That(doc.RootElement).IsBoolean(); + } + + [Test] + public async Task IsBoolean_WithNonBoolean_Fails() + { + using var doc = JsonDocument.Parse("null"); + await Assert.ThrowsAsync( + async () => await Assert.That(doc.RootElement).IsBoolean()); + } + + [Test] + public async Task IsNull_WithNull_Passes() + { + using var doc = JsonDocument.Parse("null"); + await Assert.That(doc.RootElement).IsNull(); + } + + [Test] + public async Task IsNull_WithNonNull_Fails() + { + using var doc = JsonDocument.Parse("{}"); + await Assert.ThrowsAsync( + async () => await Assert.That(doc.RootElement).IsNull()); + } + + [Test] + public async Task IsNotNull_WithObject_Passes() + { + using var doc = JsonDocument.Parse("{}"); + await Assert.That(doc.RootElement).IsNotNull(); + } + + [Test] + public async Task IsNotNull_WithNull_Fails() + { + using var doc = JsonDocument.Parse("null"); + await Assert.ThrowsAsync( + async () => await Assert.That(doc.RootElement).IsNotNull()); + } + + [Test] + public async Task HasProperty_WhenPropertyExists_Passes() + { + using var doc = JsonDocument.Parse("{\"name\":\"Alice\",\"age\":30}"); + await Assert.That(doc.RootElement).HasProperty("name"); + } + + [Test] + public async Task HasProperty_WhenPropertyMissing_Fails() + { + using var doc = JsonDocument.Parse("{\"name\":\"Alice\"}"); + await Assert.ThrowsAsync( + async () => await Assert.That(doc.RootElement).HasProperty("missing")); + } + + [Test] + public async Task DoesNotHaveProperty_WhenPropertyMissing_Passes() + { + using var doc = JsonDocument.Parse("{\"name\":\"Alice\"}"); + await Assert.That(doc.RootElement).DoesNotHaveProperty("missing"); + } + + [Test] + public async Task DoesNotHaveProperty_WhenPropertyExists_Fails() + { + using var doc = JsonDocument.Parse("{\"name\":\"Alice\"}"); + await Assert.ThrowsAsync( + async () => await Assert.That(doc.RootElement).DoesNotHaveProperty("name")); + } + +#if NET9_0_OR_GREATER + [Test] + public async Task IsDeepEqualTo_WithIdenticalJson_Passes() + { + using var doc1 = JsonDocument.Parse("{\"name\":\"Alice\",\"age\":30}"); + using var doc2 = JsonDocument.Parse("{\"name\":\"Alice\",\"age\":30}"); + await Assert.That(doc1.RootElement).IsDeepEqualTo(doc2.RootElement); + } + + [Test] + public async Task IsDeepEqualTo_WithDifferentWhitespace_Passes() + { + using var doc1 = JsonDocument.Parse("{ \"name\" : \"Alice\" }"); + using var doc2 = JsonDocument.Parse("{\"name\":\"Alice\"}"); + await Assert.That(doc1.RootElement).IsDeepEqualTo(doc2.RootElement); + } + + [Test] + public async Task IsDeepEqualTo_WithDifferentValues_FailsWithPath() + { + using var doc1 = JsonDocument.Parse("{\"name\":\"Alice\",\"age\":30}"); + using var doc2 = JsonDocument.Parse("{\"name\":\"Alice\",\"age\":31}"); + + var exception = await Assert.ThrowsAsync( + async () => await Assert.That(doc1.RootElement).IsDeepEqualTo(doc2.RootElement)); + + await Assert.That(exception.Message).Contains("$.age"); + } + + [Test] + public async Task IsNotDeepEqualTo_WithDifferentJson_Passes() + { + using var doc1 = JsonDocument.Parse("{\"name\":\"Alice\"}"); + using var doc2 = JsonDocument.Parse("{\"name\":\"Bob\"}"); + await Assert.That(doc1.RootElement).IsNotDeepEqualTo(doc2.RootElement); + } + + [Test] + public async Task IsNotDeepEqualTo_WithIdenticalJson_Fails() + { + using var doc1 = JsonDocument.Parse("{\"name\":\"Alice\"}"); + using var doc2 = JsonDocument.Parse("{\"name\":\"Alice\"}"); + await Assert.ThrowsAsync( + async () => await Assert.That(doc1.RootElement).IsNotDeepEqualTo(doc2.RootElement)); + } +#endif +} diff --git a/TUnit.Assertions.Tests/JsonNodeAssertionTests.cs b/TUnit.Assertions.Tests/JsonNodeAssertionTests.cs new file mode 100644 index 0000000000..dafb019332 --- /dev/null +++ b/TUnit.Assertions.Tests/JsonNodeAssertionTests.cs @@ -0,0 +1,184 @@ +using System.Text.Json.Nodes; +using TUnit.Assertions.Extensions; + +namespace TUnit.Assertions.Tests; + +public class JsonNodeAssertionTests +{ + [Test] + public async Task IsJsonObject_WithJsonObject_Passes() + { + JsonNode? node = JsonNode.Parse("{\"name\":\"test\"}"); + await Assert.That(node).IsJsonObject(); + } + + [Test] + public async Task IsJsonObject_WithJsonArray_Fails() + { + JsonNode? node = JsonNode.Parse("[1,2,3]"); + await Assert.ThrowsAsync( + async () => await Assert.That(node).IsJsonObject()); + } + + [Test] + public async Task IsJsonArray_WithJsonArray_Passes() + { + JsonNode? node = JsonNode.Parse("[1,2,3]"); + await Assert.That(node).IsJsonArray(); + } + + [Test] + public async Task IsJsonArray_WithJsonObject_Fails() + { + JsonNode? node = JsonNode.Parse("{\"name\":\"test\"}"); + await Assert.ThrowsAsync( + async () => await Assert.That(node).IsJsonArray()); + } + + [Test] + public async Task IsJsonValue_WithJsonValue_Passes() + { + JsonNode? node = JsonNode.Parse("42"); + await Assert.That(node).IsJsonValue(); + } + + [Test] + public async Task IsJsonValue_WithJsonObject_Fails() + { + JsonNode? node = JsonNode.Parse("{\"name\":\"test\"}"); + await Assert.ThrowsAsync( + async () => await Assert.That(node).IsJsonValue()); + } + + [Test] + public async Task HasJsonProperty_WhenPropertyExists_Passes() + { + JsonNode? node = JsonNode.Parse("{\"name\":\"Alice\"}"); + await Assert.That(node).HasJsonProperty("name"); + } + + [Test] + public async Task HasJsonProperty_WhenPropertyMissing_Fails() + { + JsonNode? node = JsonNode.Parse("{\"name\":\"Alice\"}"); + await Assert.ThrowsAsync( + async () => await Assert.That(node).HasJsonProperty("missing")); + } + + [Test] + public async Task DoesNotHaveJsonProperty_WhenPropertyMissing_Passes() + { + JsonNode? node = JsonNode.Parse("{\"name\":\"Alice\"}"); + await Assert.That(node).DoesNotHaveJsonProperty("missing"); + } + + [Test] + public async Task DoesNotHaveJsonProperty_WhenPropertyExists_Fails() + { + JsonNode? node = JsonNode.Parse("{\"name\":\"Alice\"}"); + await Assert.ThrowsAsync( + async () => await Assert.That(node).DoesNotHaveJsonProperty("name")); + } + + // JsonArray assertions - use JsonNode? to work with TUnit's assertion model + [Test] + public async Task IsJsonArrayEmpty_WithEmptyArray_Passes() + { + JsonNode? node = JsonNode.Parse("[]"); + await Assert.That(node).IsJsonArrayEmpty(); + } + + [Test] + public async Task IsJsonArrayEmpty_WithNonEmptyArray_Fails() + { + JsonNode? node = JsonNode.Parse("[1, 2, 3]"); + await Assert.ThrowsAsync( + async () => await Assert.That(node).IsJsonArrayEmpty()); + } + + [Test] + public async Task IsJsonArrayNotEmpty_WithNonEmptyArray_Passes() + { + JsonNode? node = JsonNode.Parse("[1, 2, 3]"); + await Assert.That(node).IsJsonArrayNotEmpty(); + } + + [Test] + public async Task IsJsonArrayNotEmpty_WithEmptyArray_Fails() + { + JsonNode? node = JsonNode.Parse("[]"); + await Assert.ThrowsAsync( + async () => await Assert.That(node).IsJsonArrayNotEmpty()); + } + + [Test] + public async Task HasJsonArrayCount_WithMatchingCount_Passes() + { + JsonNode? node = JsonNode.Parse("[1, 2, 3]"); + await Assert.That(node).HasJsonArrayCount(3); + } + + [Test] + public async Task HasJsonArrayCount_WithMismatchedCount_Fails() + { + JsonNode? node = JsonNode.Parse("[1, 2, 3]"); + await Assert.ThrowsAsync( + async () => await Assert.That(node).HasJsonArrayCount(5)); + } + +#if NET8_0_OR_GREATER + [Test] + public async Task IsDeepEqualTo_WithIdenticalJson_Passes() + { + JsonNode? node1 = JsonNode.Parse("{\"name\":\"Alice\",\"age\":30}"); + JsonNode? node2 = JsonNode.Parse("{\"name\":\"Alice\",\"age\":30}"); + await Assert.That(node1).IsDeepEqualTo(node2); + } + + [Test] + public async Task IsDeepEqualTo_WithDifferentWhitespace_Passes() + { + JsonNode? node1 = JsonNode.Parse("{ \"name\" : \"Alice\" }"); + JsonNode? node2 = JsonNode.Parse("{\"name\":\"Alice\"}"); + await Assert.That(node1).IsDeepEqualTo(node2); + } + + [Test] + public async Task IsDeepEqualTo_WithDifferentJson_Fails() + { + JsonNode? node1 = JsonNode.Parse("{\"name\":\"Alice\"}"); + JsonNode? node2 = JsonNode.Parse("{\"name\":\"Bob\"}"); + await Assert.ThrowsAsync( + async () => await Assert.That(node1).IsDeepEqualTo(node2)); + } + + [Test] + public async Task IsDeepEqualTo_ErrorMessageContainsPath() + { + JsonNode? node1 = JsonNode.Parse("{\"person\":{\"name\":\"Alice\",\"age\":30}}"); + JsonNode? node2 = JsonNode.Parse("{\"person\":{\"name\":\"Alice\",\"age\":31}}"); + + var exception = await Assert.ThrowsAsync( + async () => await Assert.That(node1).IsDeepEqualTo(node2)); + + await Assert.That(exception.Message).Contains("$.person.age"); + } + + [Test] + public async Task IsNotDeepEqualTo_WithDifferentJson_Passes() + { + JsonNode? node1 = JsonNode.Parse("{\"name\":\"Alice\"}"); + JsonNode? node2 = JsonNode.Parse("{\"name\":\"Bob\"}"); + await Assert.That(node1).IsNotDeepEqualTo(node2); + } + + [Test] + public async Task IsNotDeepEqualTo_WithIdenticalJson_Fails() + { + JsonNode? node1 = JsonNode.Parse("{\"name\":\"Alice\"}"); + JsonNode? node2 = JsonNode.Parse("{\"name\":\"Alice\"}"); + await Assert.ThrowsAsync( + async () => await Assert.That(node1).IsNotDeepEqualTo(node2)); + } +#endif +} diff --git a/TUnit.Assertions.Tests/JsonStringAssertionTests.cs b/TUnit.Assertions.Tests/JsonStringAssertionTests.cs new file mode 100644 index 0000000000..18b4eecfbf --- /dev/null +++ b/TUnit.Assertions.Tests/JsonStringAssertionTests.cs @@ -0,0 +1,82 @@ +using TUnit.Assertions.Extensions; + +namespace TUnit.Assertions.Tests; + +public class JsonStringAssertionTests +{ + [Test] + public async Task IsValidJson_WithValidJson_Passes() + { + var json = "{\"name\":\"Alice\"}"; + await Assert.That(json).IsValidJson(); + } + + [Test] + public async Task IsValidJson_WithInvalidJson_Fails() + { + var json = "not valid json"; + await Assert.ThrowsAsync( + async () => await Assert.That(json).IsValidJson()); + } + + [Test] + public async Task IsNotValidJson_WithInvalidJson_Passes() + { + var json = "not valid json"; + await Assert.That(json).IsNotValidJson(); + } + + [Test] + public async Task IsNotValidJson_WithValidJson_Fails() + { + var json = "{\"name\":\"Alice\"}"; + await Assert.ThrowsAsync( + async () => await Assert.That(json).IsNotValidJson()); + } + + [Test] + public async Task IsValidJsonObject_WithObject_Passes() + { + var json = "{\"name\":\"Alice\"}"; + await Assert.That(json).IsValidJsonObject(); + } + + [Test] + public async Task IsValidJsonObject_WithArray_Fails() + { + var json = "[1,2,3]"; + await Assert.ThrowsAsync( + async () => await Assert.That(json).IsValidJsonObject()); + } + + [Test] + public async Task IsValidJsonObject_WithInvalidJson_Fails() + { + var json = "not valid json"; + await Assert.ThrowsAsync( + async () => await Assert.That(json).IsValidJsonObject()); + } + + [Test] + public async Task IsValidJsonArray_WithArray_Passes() + { + var json = "[1,2,3]"; + await Assert.That(json).IsValidJsonArray(); + } + + [Test] + public async Task IsValidJsonArray_WithObject_Fails() + { + var json = "{\"name\":\"Alice\"}"; + await Assert.ThrowsAsync( + async () => await Assert.That(json).IsValidJsonArray()); + } + + [Test] + public async Task IsValidJsonArray_WithInvalidJson_Fails() + { + var json = "not valid json"; + await Assert.ThrowsAsync( + async () => await Assert.That(json).IsValidJsonArray()); + } +} diff --git a/TUnit.Assertions/Conditions/Json/JsonDiffHelper.cs b/TUnit.Assertions/Conditions/Json/JsonDiffHelper.cs new file mode 100644 index 0000000000..31b0cf41c8 --- /dev/null +++ b/TUnit.Assertions/Conditions/Json/JsonDiffHelper.cs @@ -0,0 +1,268 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace TUnit.Assertions.Conditions.Json; + +/// +/// Helper class for comparing JSON elements and identifying differences. +/// +internal static class JsonDiffHelper +{ + /// + /// Represents the result of a JSON comparison, including the path where a difference was found + /// and the expected and actual values at that location. + /// + /// The JSON path where the difference was found (e.g., "$.person.name"). + /// The expected value at this path. + /// The actual value at this path. + /// Whether a difference was found. Defaults to true. + public readonly record struct DiffResult(string Path, string Expected, string Actual, bool HasDifference = true); + + /// + /// Finds the first difference between two JSON elements. + /// + /// The actual JSON element to compare. + /// The expected JSON element to compare against. + /// A containing information about the first difference found, + /// or a result with set to false if the elements are identical. + public static DiffResult FindFirstDifference(JsonElement actual, JsonElement expected) + { + return FindDiff(actual, expected, "$"); + } + + private static DiffResult FindDiff(JsonElement actual, JsonElement expected, string path) + { + if (actual.ValueKind != expected.ValueKind) + { + return new DiffResult(path, expected.ValueKind.ToString(), actual.ValueKind.ToString()); + } + + return actual.ValueKind switch + { + JsonValueKind.Object => CompareObjects(actual, expected, path), + JsonValueKind.Array => CompareArrays(actual, expected, path), + _ => ComparePrimitives(actual, expected, path) + }; + } + + private static DiffResult CompareObjects(JsonElement actual, JsonElement expected, string path) + { + // Check for missing properties in actual that exist in expected + foreach (var prop in expected.EnumerateObject()) + { + var propPath = $"{path}.{prop.Name}"; + if (!actual.TryGetProperty(prop.Name, out var actualProp)) + { + return new DiffResult(propPath, FormatValue(prop.Value), "(missing)"); + } + + var diff = FindDiff(actualProp, prop.Value, propPath); + if (diff.HasDifference) + { + return diff; + } + } + + // Check for extra properties in actual that don't exist in expected + foreach (var prop in actual.EnumerateObject()) + { + var propPath = $"{path}.{prop.Name}"; + if (!expected.TryGetProperty(prop.Name, out _)) + { + return new DiffResult(propPath, "(missing)", FormatValue(prop.Value)); + } + } + + return new DiffResult(path, "", "", HasDifference: false); + } + + private static DiffResult CompareArrays(JsonElement actual, JsonElement expected, string path) + { + var actualLength = actual.GetArrayLength(); + var expectedLength = expected.GetArrayLength(); + + if (actualLength != expectedLength) + { + return new DiffResult($"{path}.Length", expectedLength.ToString(), actualLength.ToString()); + } + + var actualEnumerator = actual.EnumerateArray(); + var expectedEnumerator = expected.EnumerateArray(); + var index = 0; + + while (actualEnumerator.MoveNext() && expectedEnumerator.MoveNext()) + { + var itemPath = $"{path}[{index}]"; + var diff = FindDiff(actualEnumerator.Current, expectedEnumerator.Current, itemPath); + if (diff.HasDifference) + { + return diff; + } + index++; + } + + return new DiffResult(path, "", "", HasDifference: false); + } + + private static DiffResult ComparePrimitives(JsonElement actual, JsonElement expected, string path) + { + var actualText = FormatValue(actual); + var expectedText = FormatValue(expected); + + if (actualText != expectedText) + { + return new DiffResult(path, expectedText, actualText); + } + + return new DiffResult(path, "", "", HasDifference: false); + } + + private static string FormatValue(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => $"\"{element.GetString()}\"", + JsonValueKind.Number => element.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => "null", + JsonValueKind.Object => "{...}", + JsonValueKind.Array => "[...]", + _ => element.GetRawText() + }; + } + + /// + /// Finds the first difference between two JSON nodes. + /// + /// The actual JSON node to compare. + /// The expected JSON node to compare against. + /// A containing information about the first difference found, + /// or a result with set to false if the nodes are identical. + public static DiffResult FindFirstDifference(JsonNode? actual, JsonNode? expected) + { + return FindNodeDiff(actual, expected, "$"); + } + + private static DiffResult FindNodeDiff(JsonNode? actual, JsonNode? expected, string path) + { + // Handle null cases + if (actual is null && expected is null) + { + return new DiffResult(path, "", "", HasDifference: false); + } + if (actual is null) + { + return new DiffResult(path, FormatNode(expected), "null"); + } + if (expected is null) + { + return new DiffResult(path, "null", FormatNode(actual)); + } + + // Check type mismatch + if (actual.GetType() != expected.GetType()) + { + return new DiffResult(path, GetNodeTypeName(expected), GetNodeTypeName(actual)); + } + + return actual switch + { + JsonObject actualObj => CompareJsonObjects(actualObj, (JsonObject)expected, path), + JsonArray actualArr => CompareJsonArrays(actualArr, (JsonArray)expected, path), + JsonValue actualVal => CompareJsonValues(actualVal, (JsonValue)expected, path), + _ => new DiffResult(path, "", "", HasDifference: false) + }; + } + + private static DiffResult CompareJsonObjects(JsonObject actual, JsonObject expected, string path) + { + // Check for missing properties in actual that exist in expected + foreach (var prop in expected) + { + var propPath = $"{path}.{prop.Key}"; + if (!actual.TryGetPropertyValue(prop.Key, out var actualProp)) + { + return new DiffResult(propPath, FormatNode(prop.Value), "(missing)"); + } + + var diff = FindNodeDiff(actualProp, prop.Value, propPath); + if (diff.HasDifference) + { + return diff; + } + } + + // Check for extra properties in actual that don't exist in expected + foreach (var prop in actual) + { + var propPath = $"{path}.{prop.Key}"; + if (!expected.ContainsKey(prop.Key)) + { + return new DiffResult(propPath, "(missing)", FormatNode(prop.Value)); + } + } + + return new DiffResult(path, "", "", HasDifference: false); + } + + private static DiffResult CompareJsonArrays(JsonArray actual, JsonArray expected, string path) + { + if (actual.Count != expected.Count) + { + return new DiffResult($"{path}.Length", expected.Count.ToString(), actual.Count.ToString()); + } + + for (var i = 0; i < actual.Count; i++) + { + var itemPath = $"{path}[{i}]"; + var diff = FindNodeDiff(actual[i], expected[i], itemPath); + if (diff.HasDifference) + { + return diff; + } + } + + return new DiffResult(path, "", "", HasDifference: false); + } + + private static DiffResult CompareJsonValues(JsonValue actual, JsonValue expected, string path) + { + var actualText = actual.ToJsonString(); + var expectedText = expected.ToJsonString(); + + if (actualText != expectedText) + { + return new DiffResult(path, expectedText, actualText); + } + + return new DiffResult(path, "", "", HasDifference: false); + } + + private static string GetNodeTypeName(JsonNode? node) + { + return node switch + { + JsonObject => "Object", + JsonArray => "Array", + JsonValue => "Value", + _ => "null" + }; + } + + private static string FormatNode(JsonNode? node) + { + if (node is null) + { + return "null"; + } + + return node switch + { + JsonObject => "{...}", + JsonArray => "[...]", + JsonValue val => val.ToJsonString(), + _ => node.ToJsonString() + }; + } +} diff --git a/TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs b/TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs new file mode 100644 index 0000000000..004591015f --- /dev/null +++ b/TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using TUnit.Assertions.Attributes; + +namespace TUnit.Assertions.Conditions.Json; + +/// +/// Source-generated assertions for JsonElement type checking. +/// +public static partial class JsonElementAssertionExtensions +{ + [GenerateAssertion(ExpectationMessage = "to be a JSON object", InlineMethodBody = true)] + public static bool IsObject(this JsonElement value) + => value.ValueKind == JsonValueKind.Object; + + [GenerateAssertion(ExpectationMessage = "to be a JSON array", InlineMethodBody = true)] + public static bool IsArray(this JsonElement value) + => value.ValueKind == JsonValueKind.Array; + + [GenerateAssertion(ExpectationMessage = "to be a JSON string", InlineMethodBody = true)] + public static bool IsString(this JsonElement value) + => value.ValueKind == JsonValueKind.String; + + [GenerateAssertion(ExpectationMessage = "to be a JSON number", InlineMethodBody = true)] + public static bool IsNumber(this JsonElement value) + => value.ValueKind == JsonValueKind.Number; + + [GenerateAssertion(ExpectationMessage = "to be a JSON boolean", InlineMethodBody = true)] + public static bool IsBoolean(this JsonElement value) + => value.ValueKind == JsonValueKind.True || value.ValueKind == JsonValueKind.False; + + [GenerateAssertion(ExpectationMessage = "to be JSON null", InlineMethodBody = true)] + public static bool IsNull(this JsonElement value) + => value.ValueKind == JsonValueKind.Null; + + [GenerateAssertion(ExpectationMessage = "to not be JSON null", InlineMethodBody = true)] + public static bool IsNotNull(this JsonElement value) + => value.ValueKind != JsonValueKind.Null; + + [GenerateAssertion(ExpectationMessage = "to have property '{propertyName}'", InlineMethodBody = true)] + public static bool HasProperty(this JsonElement value, string propertyName) + => value.ValueKind == JsonValueKind.Object && value.TryGetProperty(propertyName, out _); + + [GenerateAssertion(ExpectationMessage = "to not have property '{propertyName}'", InlineMethodBody = true)] + public static bool DoesNotHaveProperty(this JsonElement value, string propertyName) + => value.ValueKind != JsonValueKind.Object || !value.TryGetProperty(propertyName, out _); + +#if NET9_0_OR_GREATER + [GenerateAssertion(ExpectationMessage = "to be deeply equal to {expected}")] + public static TUnit.Assertions.Core.AssertionResult IsDeepEqualTo(this JsonElement value, JsonElement expected) + { + if (JsonElement.DeepEquals(value, expected)) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + + var diff = JsonDiffHelper.FindFirstDifference(value, expected); + return TUnit.Assertions.Core.AssertionResult.Failed( + $"differs at {diff.Path}: expected {diff.Expected} but found {diff.Actual}"); + } + + [GenerateAssertion(ExpectationMessage = "to not be deeply equal to {expected}", InlineMethodBody = true)] + public static bool IsNotDeepEqualTo(this JsonElement value, JsonElement expected) + => !JsonElement.DeepEquals(value, expected); +#endif +} diff --git a/TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs b/TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs new file mode 100644 index 0000000000..23e8aa8bb7 --- /dev/null +++ b/TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs @@ -0,0 +1,132 @@ +using System.Text.Json.Nodes; +using TUnit.Assertions.Attributes; + +namespace TUnit.Assertions.Conditions.Json; + +/// +/// Source-generated assertions for JsonNode types. +/// +public static partial class JsonNodeAssertionExtensions +{ + [GenerateAssertion(ExpectationMessage = "to be a JsonObject")] + public static TUnit.Assertions.Core.AssertionResult IsJsonObject(this JsonNode? value) + { + if (value is JsonObject) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + return TUnit.Assertions.Core.AssertionResult.Failed($"found {value?.GetType().Name ?? "null"}"); + } + + [GenerateAssertion(ExpectationMessage = "to be a JsonArray")] + public static TUnit.Assertions.Core.AssertionResult IsJsonArray(this JsonNode? value) + { + if (value is JsonArray) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + return TUnit.Assertions.Core.AssertionResult.Failed($"found {value?.GetType().Name ?? "null"}"); + } + + [GenerateAssertion(ExpectationMessage = "to be a JsonValue")] + public static TUnit.Assertions.Core.AssertionResult IsJsonValue(this JsonNode? value) + { + if (value is JsonValue) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + return TUnit.Assertions.Core.AssertionResult.Failed($"found {value?.GetType().Name ?? "null"}"); + } + + [GenerateAssertion(ExpectationMessage = "to have property '{propertyName}'")] + public static TUnit.Assertions.Core.AssertionResult HasJsonProperty(this JsonNode? value, string propertyName) + { + if (value is JsonObject obj && obj.ContainsKey(propertyName)) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + if (value is not JsonObject) + { + return TUnit.Assertions.Core.AssertionResult.Failed($"found {value?.GetType().Name ?? "null"} instead of JsonObject"); + } + return TUnit.Assertions.Core.AssertionResult.Failed($"property '{propertyName}' not found"); + } + + [GenerateAssertion(ExpectationMessage = "to not have property '{propertyName}'")] + public static TUnit.Assertions.Core.AssertionResult DoesNotHaveJsonProperty(this JsonNode? value, string propertyName) + { + if (value is not JsonObject obj || !obj.ContainsKey(propertyName)) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + return TUnit.Assertions.Core.AssertionResult.Failed($"property '{propertyName}' was found"); + } + + // JsonArray assertions - work on JsonNode? to avoid collection assertion wrapping issues + [GenerateAssertion(ExpectationMessage = "to be an empty JSON array")] + public static TUnit.Assertions.Core.AssertionResult IsJsonArrayEmpty(this JsonNode? value) + { + if (value is not JsonArray array) + { + return TUnit.Assertions.Core.AssertionResult.Failed($"found {value?.GetType().Name ?? "null"} instead of JsonArray"); + } + if (array.Count == 0) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + return TUnit.Assertions.Core.AssertionResult.Failed($"has {array.Count} elements"); + } + + [GenerateAssertion(ExpectationMessage = "to not be an empty JSON array")] + public static TUnit.Assertions.Core.AssertionResult IsJsonArrayNotEmpty(this JsonNode? value) + { + if (value is not JsonArray array) + { + return TUnit.Assertions.Core.AssertionResult.Failed($"found {value?.GetType().Name ?? "null"} instead of JsonArray"); + } + if (array.Count > 0) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + return TUnit.Assertions.Core.AssertionResult.Failed("array is empty"); + } + + [GenerateAssertion(ExpectationMessage = "to have {expected} elements")] + public static TUnit.Assertions.Core.AssertionResult HasJsonArrayCount(this JsonNode? value, int expected) + { + if (value is not JsonArray array) + { + return TUnit.Assertions.Core.AssertionResult.Failed($"found {value?.GetType().Name ?? "null"} instead of JsonArray"); + } + if (array.Count == expected) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + return TUnit.Assertions.Core.AssertionResult.Failed($"has {array.Count} elements"); + } + +#if NET8_0_OR_GREATER + [GenerateAssertion(ExpectationMessage = "to be equal to {expected}")] + public static TUnit.Assertions.Core.AssertionResult IsDeepEqualTo(this JsonNode? value, JsonNode? expected) + { + if (JsonNode.DeepEquals(value, expected)) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + + var diff = JsonDiffHelper.FindFirstDifference(value, expected); + return TUnit.Assertions.Core.AssertionResult.Failed( + $"differs at {diff.Path}: expected {diff.Expected} but found {diff.Actual}"); + } + + [GenerateAssertion(ExpectationMessage = "to not be equal to {expected}")] + public static TUnit.Assertions.Core.AssertionResult IsNotDeepEqualTo(this JsonNode? value, JsonNode? expected) + { + if (!JsonNode.DeepEquals(value, expected)) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + return TUnit.Assertions.Core.AssertionResult.Failed("values are equal"); + } +#endif +} diff --git a/TUnit.Assertions/Conditions/Json/JsonStringAssertionExtensions.cs b/TUnit.Assertions/Conditions/Json/JsonStringAssertionExtensions.cs new file mode 100644 index 0000000000..6d8a1fb25e --- /dev/null +++ b/TUnit.Assertions/Conditions/Json/JsonStringAssertionExtensions.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using TUnit.Assertions.Attributes; +using TUnit.Assertions.Core; + +namespace TUnit.Assertions.Conditions.Json; + +/// +/// Source-generated assertions for validating JSON strings. +/// +public static partial class JsonStringAssertionExtensions +{ + [GenerateAssertion(ExpectationMessage = "to be valid JSON")] + public static AssertionResult IsValidJson(this string value) + { + try + { + using var doc = JsonDocument.Parse(value); + return AssertionResult.Passed; + } + catch (JsonException ex) + { + return AssertionResult.Failed($"is not valid JSON: {ex.Message}"); + } + } + + [GenerateAssertion(ExpectationMessage = "to not be valid JSON")] + public static AssertionResult IsNotValidJson(this string value) + { + try + { + using var doc = JsonDocument.Parse(value); + return AssertionResult.Failed("is valid JSON"); + } + catch (JsonException) + { + return AssertionResult.Passed; + } + } + + [GenerateAssertion(ExpectationMessage = "to be a valid JSON object")] + public static AssertionResult IsValidJsonObject(this string value) + { + try + { + using var doc = JsonDocument.Parse(value); + if (doc.RootElement.ValueKind == JsonValueKind.Object) + { + return AssertionResult.Passed; + } + return AssertionResult.Failed($"is a {doc.RootElement.ValueKind}, not an Object"); + } + catch (JsonException ex) + { + return AssertionResult.Failed($"is not valid JSON: {ex.Message}"); + } + } + + [GenerateAssertion(ExpectationMessage = "to be a valid JSON array")] + public static AssertionResult IsValidJsonArray(this string value) + { + try + { + using var doc = JsonDocument.Parse(value); + if (doc.RootElement.ValueKind == JsonValueKind.Array) + { + return AssertionResult.Passed; + } + return AssertionResult.Failed($"is a {doc.RootElement.ValueKind}, not an Array"); + } + catch (JsonException ex) + { + return AssertionResult.Failed($"is not valid JSON: {ex.Message}"); + } + } +} diff --git a/TUnit.Assertions/TUnit.Assertions.csproj b/TUnit.Assertions/TUnit.Assertions.csproj index faebe00897..e2fb098ee5 100644 --- a/TUnit.Assertions/TUnit.Assertions.csproj +++ b/TUnit.Assertions/TUnit.Assertions.csproj @@ -54,6 +54,7 @@ + diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 193caf076a..1d4c70b02f 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1380,6 +1380,68 @@ namespace . } } namespace . +{ + public static class JsonElementAssertionExtensions + { + [.(ExpectationMessage="to not have property \'{propertyName}\'", InlineMethodBody=true)] + public static bool DoesNotHaveProperty(this . value, string propertyName) { } + [.(ExpectationMessage="to have property \'{propertyName}\'", InlineMethodBody=true)] + public static bool HasProperty(this . value, string propertyName) { } + [.(ExpectationMessage="to be a JSON array", InlineMethodBody=true)] + public static bool IsArray(this . value) { } + [.(ExpectationMessage="to be a JSON boolean", InlineMethodBody=true)] + public static bool IsBoolean(this . value) { } + [.(ExpectationMessage="to be deeply equal to {expected}")] + public static . IsDeepEqualTo(this . value, . expected) { } + [.(ExpectationMessage="to not be deeply equal to {expected}", InlineMethodBody=true)] + public static bool IsNotDeepEqualTo(this . value, . expected) { } + [.(ExpectationMessage="to not be JSON null", InlineMethodBody=true)] + public static bool IsNotNull(this . value) { } + [.(ExpectationMessage="to be JSON null", InlineMethodBody=true)] + public static bool IsNull(this . value) { } + [.(ExpectationMessage="to be a JSON number", InlineMethodBody=true)] + public static bool IsNumber(this . value) { } + [.(ExpectationMessage="to be a JSON object", InlineMethodBody=true)] + public static bool IsObject(this . value) { } + [.(ExpectationMessage="to be a JSON string", InlineMethodBody=true)] + public static bool IsString(this . value) { } + } + public static class JsonNodeAssertionExtensions + { + [.(ExpectationMessage="to not have property \'{propertyName}\'")] + public static . DoesNotHaveJsonProperty(this ..JsonNode? value, string propertyName) { } + [.(ExpectationMessage="to have {expected} elements")] + public static . HasJsonArrayCount(this ..JsonNode? value, int expected) { } + [.(ExpectationMessage="to have property \'{propertyName}\'")] + public static . HasJsonProperty(this ..JsonNode? value, string propertyName) { } + [.(ExpectationMessage="to be equal to {expected}")] + public static . IsDeepEqualTo(this ..JsonNode? value, ..JsonNode? expected) { } + [.(ExpectationMessage="to be a JsonArray")] + public static . IsJsonArray(this ..JsonNode? value) { } + [.(ExpectationMessage="to be an empty JSON array")] + public static . IsJsonArrayEmpty(this ..JsonNode? value) { } + [.(ExpectationMessage="to not be an empty JSON array")] + public static . IsJsonArrayNotEmpty(this ..JsonNode? value) { } + [.(ExpectationMessage="to be a JsonObject")] + public static . IsJsonObject(this ..JsonNode? value) { } + [.(ExpectationMessage="to be a JsonValue")] + public static . IsJsonValue(this ..JsonNode? value) { } + [.(ExpectationMessage="to not be equal to {expected}")] + public static . IsNotDeepEqualTo(this ..JsonNode? value, ..JsonNode? expected) { } + } + public static class JsonStringAssertionExtensions + { + [.(ExpectationMessage="to not be valid JSON")] + public static . IsNotValidJson(this string value) { } + [.(ExpectationMessage="to be valid JSON")] + public static . IsValidJson(this string value) { } + [.(ExpectationMessage="to be a valid JSON array")] + public static . IsValidJsonArray(this string value) { } + [.(ExpectationMessage="to be a valid JSON object")] + public static . IsValidJsonObject(this string value) { } + } +} +namespace . { public class CountWrapper : ., . where TCollection : . @@ -3138,6 +3200,166 @@ namespace .Extensions public static . IsNotDefault(this . source) where TValue : class { } } + public static class JsonElementAssertionExtensions + { + public static ._DoesNotHaveProperty_String_Assertion DoesNotHaveProperty(this .<.> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._HasProperty_String_Assertion HasProperty(this .<.> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._IsArray_Assertion IsArray(this .<.> source) { } + public static ._IsBoolean_Assertion IsBoolean(this .<.> source) { } + public static ._IsDeepEqualTo_JsonElement_Assertion IsDeepEqualTo(this .<.> source, . expected, [.("expected")] string? expectedExpression = null) { } + public static ._IsNotDeepEqualTo_JsonElement_Assertion IsNotDeepEqualTo(this .<.> source, . expected, [.("expected")] string? expectedExpression = null) { } + public static ._IsNotNull_Assertion IsNotNull(this .<.> source) { } + public static ._IsNull_Assertion IsNull(this .<.> source) { } + public static ._IsNumber_Assertion IsNumber(this .<.> source) { } + public static ._IsObject_Assertion IsObject(this .<.> source) { } + public static ._IsString_Assertion IsString(this .<.> source) { } + } + public sealed class JsonElement_DoesNotHaveProperty_String_Assertion : .<.> + { + public JsonElement_DoesNotHaveProperty_String_Assertion(.<.> context, string propertyName) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_HasProperty_String_Assertion : .<.> + { + public JsonElement_HasProperty_String_Assertion(.<.> context, string propertyName) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsArray_Assertion : .<.> + { + public JsonElement_IsArray_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsBoolean_Assertion : .<.> + { + public JsonElement_IsBoolean_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsDeepEqualTo_JsonElement_Assertion : .<.> + { + public JsonElement_IsDeepEqualTo_JsonElement_Assertion(.<.> context, . expected) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNotDeepEqualTo_JsonElement_Assertion : .<.> + { + public JsonElement_IsNotDeepEqualTo_JsonElement_Assertion(.<.> context, . expected) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNotNull_Assertion : .<.> + { + public JsonElement_IsNotNull_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNull_Assertion : .<.> + { + public JsonElement_IsNull_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNumber_Assertion : .<.> + { + public JsonElement_IsNumber_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsObject_Assertion : .<.> + { + public JsonElement_IsObject_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsString_Assertion : .<.> + { + public JsonElement_IsString_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public static class JsonNodeAssertionExtensions + { + public static ._DoesNotHaveJsonProperty_String_Assertion DoesNotHaveJsonProperty(this .<..JsonNode?> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._HasJsonArrayCount_Int_Assertion HasJsonArrayCount(this .<..JsonNode?> source, int expected, [.("expected")] string? expectedExpression = null) { } + public static ._HasJsonProperty_String_Assertion HasJsonProperty(this .<..JsonNode?> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._IsDeepEqualTo_JsonNode_Assertion IsDeepEqualTo(this .<..JsonNode?> source, ..JsonNode? expected, [.("expected")] string? expectedExpression = null) { } + public static ._IsJsonArray_Assertion IsJsonArray(this .<..JsonNode?> source) { } + public static ._IsJsonArrayEmpty_Assertion IsJsonArrayEmpty(this .<..JsonNode?> source) { } + public static ._IsJsonArrayNotEmpty_Assertion IsJsonArrayNotEmpty(this .<..JsonNode?> source) { } + public static ._IsJsonObject_Assertion IsJsonObject(this .<..JsonNode?> source) { } + public static ._IsJsonValue_Assertion IsJsonValue(this .<..JsonNode?> source) { } + public static ._IsNotDeepEqualTo_JsonNode_Assertion IsNotDeepEqualTo(this .<..JsonNode?> source, ..JsonNode? expected, [.("expected")] string? expectedExpression = null) { } + } + public sealed class JsonNode_DoesNotHaveJsonProperty_String_Assertion : .<..JsonNode?> + { + public JsonNode_DoesNotHaveJsonProperty_String_Assertion(.<..JsonNode?> context, string propertyName) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_HasJsonArrayCount_Int_Assertion : .<..JsonNode?> + { + public JsonNode_HasJsonArrayCount_Int_Assertion(.<..JsonNode?> context, int expected) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_HasJsonProperty_String_Assertion : .<..JsonNode?> + { + public JsonNode_HasJsonProperty_String_Assertion(.<..JsonNode?> context, string propertyName) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsDeepEqualTo_JsonNode_Assertion : .<..JsonNode?> + { + public JsonNode_IsDeepEqualTo_JsonNode_Assertion(.<..JsonNode?> context, ..JsonNode? expected) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArrayEmpty_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArrayEmpty_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArrayNotEmpty_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArrayNotEmpty_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArray_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArray_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonObject_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonObject_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonValue_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonValue_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsNotDeepEqualTo_JsonNode_Assertion : .<..JsonNode?> + { + public JsonNode_IsNotDeepEqualTo_JsonNode_Assertion(.<..JsonNode?> context, ..JsonNode? expected) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public static class JsonStringAssertionExtensions + { + public static ._IsNotValidJson_Assertion IsNotValidJson(this . source) { } + public static ._IsValidJson_Assertion IsValidJson(this . source) { } + public static ._IsValidJsonArray_Assertion IsValidJsonArray(this . source) { } + public static ._IsValidJsonObject_Assertion IsValidJsonObject(this . source) { } + } public static class LazyAssertionExtensions { [.("Trimming", "IL2091", Justification="Generic type parameter is only used for property access, not instantiation")] @@ -3643,6 +3865,30 @@ namespace .Extensions public static . IsNullOrEmpty(this . source) { } public static . IsNullOrWhiteSpace(this . source) { } } + public sealed class String_IsNotValidJson_Assertion : . + { + public String_IsNotValidJson_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJsonArray_Assertion : . + { + public String_IsValidJsonArray_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJsonObject_Assertion : . + { + public String_IsValidJsonObject_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJson_Assertion : . + { + public String_IsValidJson_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } [.("Trimming", "IL2091", Justification="Generic type parameter is only used for property access, not instantiation")] public sealed class T_IsIn_IEnumerableT_Assertion : . { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt index cdf708e41b..406d0957cf 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1375,6 +1375,64 @@ namespace . } } namespace . +{ + public static class JsonElementAssertionExtensions + { + [.(ExpectationMessage="to not have property \'{propertyName}\'", InlineMethodBody=true)] + public static bool DoesNotHaveProperty(this . value, string propertyName) { } + [.(ExpectationMessage="to have property \'{propertyName}\'", InlineMethodBody=true)] + public static bool HasProperty(this . value, string propertyName) { } + [.(ExpectationMessage="to be a JSON array", InlineMethodBody=true)] + public static bool IsArray(this . value) { } + [.(ExpectationMessage="to be a JSON boolean", InlineMethodBody=true)] + public static bool IsBoolean(this . value) { } + [.(ExpectationMessage="to not be JSON null", InlineMethodBody=true)] + public static bool IsNotNull(this . value) { } + [.(ExpectationMessage="to be JSON null", InlineMethodBody=true)] + public static bool IsNull(this . value) { } + [.(ExpectationMessage="to be a JSON number", InlineMethodBody=true)] + public static bool IsNumber(this . value) { } + [.(ExpectationMessage="to be a JSON object", InlineMethodBody=true)] + public static bool IsObject(this . value) { } + [.(ExpectationMessage="to be a JSON string", InlineMethodBody=true)] + public static bool IsString(this . value) { } + } + public static class JsonNodeAssertionExtensions + { + [.(ExpectationMessage="to not have property \'{propertyName}\'")] + public static . DoesNotHaveJsonProperty(this ..JsonNode? value, string propertyName) { } + [.(ExpectationMessage="to have {expected} elements")] + public static . HasJsonArrayCount(this ..JsonNode? value, int expected) { } + [.(ExpectationMessage="to have property \'{propertyName}\'")] + public static . HasJsonProperty(this ..JsonNode? value, string propertyName) { } + [.(ExpectationMessage="to be equal to {expected}")] + public static . IsDeepEqualTo(this ..JsonNode? value, ..JsonNode? expected) { } + [.(ExpectationMessage="to be a JsonArray")] + public static . IsJsonArray(this ..JsonNode? value) { } + [.(ExpectationMessage="to be an empty JSON array")] + public static . IsJsonArrayEmpty(this ..JsonNode? value) { } + [.(ExpectationMessage="to not be an empty JSON array")] + public static . IsJsonArrayNotEmpty(this ..JsonNode? value) { } + [.(ExpectationMessage="to be a JsonObject")] + public static . IsJsonObject(this ..JsonNode? value) { } + [.(ExpectationMessage="to be a JsonValue")] + public static . IsJsonValue(this ..JsonNode? value) { } + [.(ExpectationMessage="to not be equal to {expected}")] + public static . IsNotDeepEqualTo(this ..JsonNode? value, ..JsonNode? expected) { } + } + public static class JsonStringAssertionExtensions + { + [.(ExpectationMessage="to not be valid JSON")] + public static . IsNotValidJson(this string value) { } + [.(ExpectationMessage="to be valid JSON")] + public static . IsValidJson(this string value) { } + [.(ExpectationMessage="to be a valid JSON array")] + public static . IsValidJsonArray(this string value) { } + [.(ExpectationMessage="to be a valid JSON object")] + public static . IsValidJsonObject(this string value) { } + } +} +namespace . { public class CountWrapper : ., . where TCollection : . @@ -3119,6 +3177,152 @@ namespace .Extensions public static . IsNotDefault(this . source) where TValue : class { } } + public static class JsonElementAssertionExtensions + { + public static ._DoesNotHaveProperty_String_Assertion DoesNotHaveProperty(this .<.> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._HasProperty_String_Assertion HasProperty(this .<.> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._IsArray_Assertion IsArray(this .<.> source) { } + public static ._IsBoolean_Assertion IsBoolean(this .<.> source) { } + public static ._IsNotNull_Assertion IsNotNull(this .<.> source) { } + public static ._IsNull_Assertion IsNull(this .<.> source) { } + public static ._IsNumber_Assertion IsNumber(this .<.> source) { } + public static ._IsObject_Assertion IsObject(this .<.> source) { } + public static ._IsString_Assertion IsString(this .<.> source) { } + } + public sealed class JsonElement_DoesNotHaveProperty_String_Assertion : .<.> + { + public JsonElement_DoesNotHaveProperty_String_Assertion(.<.> context, string propertyName) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_HasProperty_String_Assertion : .<.> + { + public JsonElement_HasProperty_String_Assertion(.<.> context, string propertyName) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsArray_Assertion : .<.> + { + public JsonElement_IsArray_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsBoolean_Assertion : .<.> + { + public JsonElement_IsBoolean_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNotNull_Assertion : .<.> + { + public JsonElement_IsNotNull_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNull_Assertion : .<.> + { + public JsonElement_IsNull_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNumber_Assertion : .<.> + { + public JsonElement_IsNumber_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsObject_Assertion : .<.> + { + public JsonElement_IsObject_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsString_Assertion : .<.> + { + public JsonElement_IsString_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public static class JsonNodeAssertionExtensions + { + public static ._DoesNotHaveJsonProperty_String_Assertion DoesNotHaveJsonProperty(this .<..JsonNode?> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._HasJsonArrayCount_Int_Assertion HasJsonArrayCount(this .<..JsonNode?> source, int expected, [.("expected")] string? expectedExpression = null) { } + public static ._HasJsonProperty_String_Assertion HasJsonProperty(this .<..JsonNode?> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._IsDeepEqualTo_JsonNode_Assertion IsDeepEqualTo(this .<..JsonNode?> source, ..JsonNode? expected, [.("expected")] string? expectedExpression = null) { } + public static ._IsJsonArray_Assertion IsJsonArray(this .<..JsonNode?> source) { } + public static ._IsJsonArrayEmpty_Assertion IsJsonArrayEmpty(this .<..JsonNode?> source) { } + public static ._IsJsonArrayNotEmpty_Assertion IsJsonArrayNotEmpty(this .<..JsonNode?> source) { } + public static ._IsJsonObject_Assertion IsJsonObject(this .<..JsonNode?> source) { } + public static ._IsJsonValue_Assertion IsJsonValue(this .<..JsonNode?> source) { } + public static ._IsNotDeepEqualTo_JsonNode_Assertion IsNotDeepEqualTo(this .<..JsonNode?> source, ..JsonNode? expected, [.("expected")] string? expectedExpression = null) { } + } + public sealed class JsonNode_DoesNotHaveJsonProperty_String_Assertion : .<..JsonNode?> + { + public JsonNode_DoesNotHaveJsonProperty_String_Assertion(.<..JsonNode?> context, string propertyName) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_HasJsonArrayCount_Int_Assertion : .<..JsonNode?> + { + public JsonNode_HasJsonArrayCount_Int_Assertion(.<..JsonNode?> context, int expected) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_HasJsonProperty_String_Assertion : .<..JsonNode?> + { + public JsonNode_HasJsonProperty_String_Assertion(.<..JsonNode?> context, string propertyName) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsDeepEqualTo_JsonNode_Assertion : .<..JsonNode?> + { + public JsonNode_IsDeepEqualTo_JsonNode_Assertion(.<..JsonNode?> context, ..JsonNode? expected) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArrayEmpty_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArrayEmpty_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArrayNotEmpty_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArrayNotEmpty_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArray_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArray_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonObject_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonObject_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonValue_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonValue_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsNotDeepEqualTo_JsonNode_Assertion : .<..JsonNode?> + { + public JsonNode_IsNotDeepEqualTo_JsonNode_Assertion(.<..JsonNode?> context, ..JsonNode? expected) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public static class JsonStringAssertionExtensions + { + public static ._IsNotValidJson_Assertion IsNotValidJson(this . source) { } + public static ._IsValidJson_Assertion IsValidJson(this . source) { } + public static ._IsValidJsonArray_Assertion IsValidJsonArray(this . source) { } + public static ._IsValidJsonObject_Assertion IsValidJsonObject(this . source) { } + } public static class LazyAssertionExtensions { [.("Trimming", "IL2091", Justification="Generic type parameter is only used for property access, not instantiation")] @@ -3623,6 +3827,30 @@ namespace .Extensions public static . IsNullOrEmpty(this . source) { } public static . IsNullOrWhiteSpace(this . source) { } } + public sealed class String_IsNotValidJson_Assertion : . + { + public String_IsNotValidJson_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJsonArray_Assertion : . + { + public String_IsValidJsonArray_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJsonObject_Assertion : . + { + public String_IsValidJsonObject_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJson_Assertion : . + { + public String_IsValidJson_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } [.("Trimming", "IL2091", Justification="Generic type parameter is only used for property access, not instantiation")] public sealed class T_IsIn_IEnumerableT_Assertion : . { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt index de701714b4..30986ae454 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1380,6 +1380,68 @@ namespace . } } namespace . +{ + public static class JsonElementAssertionExtensions + { + [.(ExpectationMessage="to not have property \'{propertyName}\'", InlineMethodBody=true)] + public static bool DoesNotHaveProperty(this . value, string propertyName) { } + [.(ExpectationMessage="to have property \'{propertyName}\'", InlineMethodBody=true)] + public static bool HasProperty(this . value, string propertyName) { } + [.(ExpectationMessage="to be a JSON array", InlineMethodBody=true)] + public static bool IsArray(this . value) { } + [.(ExpectationMessage="to be a JSON boolean", InlineMethodBody=true)] + public static bool IsBoolean(this . value) { } + [.(ExpectationMessage="to be deeply equal to {expected}")] + public static . IsDeepEqualTo(this . value, . expected) { } + [.(ExpectationMessage="to not be deeply equal to {expected}", InlineMethodBody=true)] + public static bool IsNotDeepEqualTo(this . value, . expected) { } + [.(ExpectationMessage="to not be JSON null", InlineMethodBody=true)] + public static bool IsNotNull(this . value) { } + [.(ExpectationMessage="to be JSON null", InlineMethodBody=true)] + public static bool IsNull(this . value) { } + [.(ExpectationMessage="to be a JSON number", InlineMethodBody=true)] + public static bool IsNumber(this . value) { } + [.(ExpectationMessage="to be a JSON object", InlineMethodBody=true)] + public static bool IsObject(this . value) { } + [.(ExpectationMessage="to be a JSON string", InlineMethodBody=true)] + public static bool IsString(this . value) { } + } + public static class JsonNodeAssertionExtensions + { + [.(ExpectationMessage="to not have property \'{propertyName}\'")] + public static . DoesNotHaveJsonProperty(this ..JsonNode? value, string propertyName) { } + [.(ExpectationMessage="to have {expected} elements")] + public static . HasJsonArrayCount(this ..JsonNode? value, int expected) { } + [.(ExpectationMessage="to have property \'{propertyName}\'")] + public static . HasJsonProperty(this ..JsonNode? value, string propertyName) { } + [.(ExpectationMessage="to be equal to {expected}")] + public static . IsDeepEqualTo(this ..JsonNode? value, ..JsonNode? expected) { } + [.(ExpectationMessage="to be a JsonArray")] + public static . IsJsonArray(this ..JsonNode? value) { } + [.(ExpectationMessage="to be an empty JSON array")] + public static . IsJsonArrayEmpty(this ..JsonNode? value) { } + [.(ExpectationMessage="to not be an empty JSON array")] + public static . IsJsonArrayNotEmpty(this ..JsonNode? value) { } + [.(ExpectationMessage="to be a JsonObject")] + public static . IsJsonObject(this ..JsonNode? value) { } + [.(ExpectationMessage="to be a JsonValue")] + public static . IsJsonValue(this ..JsonNode? value) { } + [.(ExpectationMessage="to not be equal to {expected}")] + public static . IsNotDeepEqualTo(this ..JsonNode? value, ..JsonNode? expected) { } + } + public static class JsonStringAssertionExtensions + { + [.(ExpectationMessage="to not be valid JSON")] + public static . IsNotValidJson(this string value) { } + [.(ExpectationMessage="to be valid JSON")] + public static . IsValidJson(this string value) { } + [.(ExpectationMessage="to be a valid JSON array")] + public static . IsValidJsonArray(this string value) { } + [.(ExpectationMessage="to be a valid JSON object")] + public static . IsValidJsonObject(this string value) { } + } +} +namespace . { public class CountWrapper : ., . where TCollection : . @@ -3138,6 +3200,166 @@ namespace .Extensions public static . IsNotDefault(this . source) where TValue : class { } } + public static class JsonElementAssertionExtensions + { + public static ._DoesNotHaveProperty_String_Assertion DoesNotHaveProperty(this .<.> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._HasProperty_String_Assertion HasProperty(this .<.> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._IsArray_Assertion IsArray(this .<.> source) { } + public static ._IsBoolean_Assertion IsBoolean(this .<.> source) { } + public static ._IsDeepEqualTo_JsonElement_Assertion IsDeepEqualTo(this .<.> source, . expected, [.("expected")] string? expectedExpression = null) { } + public static ._IsNotDeepEqualTo_JsonElement_Assertion IsNotDeepEqualTo(this .<.> source, . expected, [.("expected")] string? expectedExpression = null) { } + public static ._IsNotNull_Assertion IsNotNull(this .<.> source) { } + public static ._IsNull_Assertion IsNull(this .<.> source) { } + public static ._IsNumber_Assertion IsNumber(this .<.> source) { } + public static ._IsObject_Assertion IsObject(this .<.> source) { } + public static ._IsString_Assertion IsString(this .<.> source) { } + } + public sealed class JsonElement_DoesNotHaveProperty_String_Assertion : .<.> + { + public JsonElement_DoesNotHaveProperty_String_Assertion(.<.> context, string propertyName) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_HasProperty_String_Assertion : .<.> + { + public JsonElement_HasProperty_String_Assertion(.<.> context, string propertyName) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsArray_Assertion : .<.> + { + public JsonElement_IsArray_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsBoolean_Assertion : .<.> + { + public JsonElement_IsBoolean_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsDeepEqualTo_JsonElement_Assertion : .<.> + { + public JsonElement_IsDeepEqualTo_JsonElement_Assertion(.<.> context, . expected) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNotDeepEqualTo_JsonElement_Assertion : .<.> + { + public JsonElement_IsNotDeepEqualTo_JsonElement_Assertion(.<.> context, . expected) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNotNull_Assertion : .<.> + { + public JsonElement_IsNotNull_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNull_Assertion : .<.> + { + public JsonElement_IsNull_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNumber_Assertion : .<.> + { + public JsonElement_IsNumber_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsObject_Assertion : .<.> + { + public JsonElement_IsObject_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsString_Assertion : .<.> + { + public JsonElement_IsString_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public static class JsonNodeAssertionExtensions + { + public static ._DoesNotHaveJsonProperty_String_Assertion DoesNotHaveJsonProperty(this .<..JsonNode?> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._HasJsonArrayCount_Int_Assertion HasJsonArrayCount(this .<..JsonNode?> source, int expected, [.("expected")] string? expectedExpression = null) { } + public static ._HasJsonProperty_String_Assertion HasJsonProperty(this .<..JsonNode?> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._IsDeepEqualTo_JsonNode_Assertion IsDeepEqualTo(this .<..JsonNode?> source, ..JsonNode? expected, [.("expected")] string? expectedExpression = null) { } + public static ._IsJsonArray_Assertion IsJsonArray(this .<..JsonNode?> source) { } + public static ._IsJsonArrayEmpty_Assertion IsJsonArrayEmpty(this .<..JsonNode?> source) { } + public static ._IsJsonArrayNotEmpty_Assertion IsJsonArrayNotEmpty(this .<..JsonNode?> source) { } + public static ._IsJsonObject_Assertion IsJsonObject(this .<..JsonNode?> source) { } + public static ._IsJsonValue_Assertion IsJsonValue(this .<..JsonNode?> source) { } + public static ._IsNotDeepEqualTo_JsonNode_Assertion IsNotDeepEqualTo(this .<..JsonNode?> source, ..JsonNode? expected, [.("expected")] string? expectedExpression = null) { } + } + public sealed class JsonNode_DoesNotHaveJsonProperty_String_Assertion : .<..JsonNode?> + { + public JsonNode_DoesNotHaveJsonProperty_String_Assertion(.<..JsonNode?> context, string propertyName) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_HasJsonArrayCount_Int_Assertion : .<..JsonNode?> + { + public JsonNode_HasJsonArrayCount_Int_Assertion(.<..JsonNode?> context, int expected) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_HasJsonProperty_String_Assertion : .<..JsonNode?> + { + public JsonNode_HasJsonProperty_String_Assertion(.<..JsonNode?> context, string propertyName) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsDeepEqualTo_JsonNode_Assertion : .<..JsonNode?> + { + public JsonNode_IsDeepEqualTo_JsonNode_Assertion(.<..JsonNode?> context, ..JsonNode? expected) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArrayEmpty_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArrayEmpty_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArrayNotEmpty_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArrayNotEmpty_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArray_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArray_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonObject_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonObject_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonValue_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonValue_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsNotDeepEqualTo_JsonNode_Assertion : .<..JsonNode?> + { + public JsonNode_IsNotDeepEqualTo_JsonNode_Assertion(.<..JsonNode?> context, ..JsonNode? expected) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public static class JsonStringAssertionExtensions + { + public static ._IsNotValidJson_Assertion IsNotValidJson(this . source) { } + public static ._IsValidJson_Assertion IsValidJson(this . source) { } + public static ._IsValidJsonArray_Assertion IsValidJsonArray(this . source) { } + public static ._IsValidJsonObject_Assertion IsValidJsonObject(this . source) { } + } public static class LazyAssertionExtensions { [.("Trimming", "IL2091", Justification="Generic type parameter is only used for property access, not instantiation")] @@ -3643,6 +3865,30 @@ namespace .Extensions public static . IsNullOrEmpty(this . source) { } public static . IsNullOrWhiteSpace(this . source) { } } + public sealed class String_IsNotValidJson_Assertion : . + { + public String_IsNotValidJson_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJsonArray_Assertion : . + { + public String_IsValidJsonArray_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJsonObject_Assertion : . + { + public String_IsValidJsonObject_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJson_Assertion : . + { + public String_IsValidJson_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } [.("Trimming", "IL2091", Justification="Generic type parameter is only used for property access, not instantiation")] public sealed class T_IsIn_IEnumerableT_Assertion : . { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt index 702517902b..3d72a6033d 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1290,6 +1290,60 @@ namespace . } } namespace . +{ + public static class JsonElementAssertionExtensions + { + [.(ExpectationMessage="to not have property \'{propertyName}\'", InlineMethodBody=true)] + public static bool DoesNotHaveProperty(this . value, string propertyName) { } + [.(ExpectationMessage="to have property \'{propertyName}\'", InlineMethodBody=true)] + public static bool HasProperty(this . value, string propertyName) { } + [.(ExpectationMessage="to be a JSON array", InlineMethodBody=true)] + public static bool IsArray(this . value) { } + [.(ExpectationMessage="to be a JSON boolean", InlineMethodBody=true)] + public static bool IsBoolean(this . value) { } + [.(ExpectationMessage="to not be JSON null", InlineMethodBody=true)] + public static bool IsNotNull(this . value) { } + [.(ExpectationMessage="to be JSON null", InlineMethodBody=true)] + public static bool IsNull(this . value) { } + [.(ExpectationMessage="to be a JSON number", InlineMethodBody=true)] + public static bool IsNumber(this . value) { } + [.(ExpectationMessage="to be a JSON object", InlineMethodBody=true)] + public static bool IsObject(this . value) { } + [.(ExpectationMessage="to be a JSON string", InlineMethodBody=true)] + public static bool IsString(this . value) { } + } + public static class JsonNodeAssertionExtensions + { + [.(ExpectationMessage="to not have property \'{propertyName}\'")] + public static . DoesNotHaveJsonProperty(this ..JsonNode? value, string propertyName) { } + [.(ExpectationMessage="to have {expected} elements")] + public static . HasJsonArrayCount(this ..JsonNode? value, int expected) { } + [.(ExpectationMessage="to have property \'{propertyName}\'")] + public static . HasJsonProperty(this ..JsonNode? value, string propertyName) { } + [.(ExpectationMessage="to be a JsonArray")] + public static . IsJsonArray(this ..JsonNode? value) { } + [.(ExpectationMessage="to be an empty JSON array")] + public static . IsJsonArrayEmpty(this ..JsonNode? value) { } + [.(ExpectationMessage="to not be an empty JSON array")] + public static . IsJsonArrayNotEmpty(this ..JsonNode? value) { } + [.(ExpectationMessage="to be a JsonObject")] + public static . IsJsonObject(this ..JsonNode? value) { } + [.(ExpectationMessage="to be a JsonValue")] + public static . IsJsonValue(this ..JsonNode? value) { } + } + public static class JsonStringAssertionExtensions + { + [.(ExpectationMessage="to not be valid JSON")] + public static . IsNotValidJson(this string value) { } + [.(ExpectationMessage="to be valid JSON")] + public static . IsValidJson(this string value) { } + [.(ExpectationMessage="to be a valid JSON array")] + public static . IsValidJsonArray(this string value) { } + [.(ExpectationMessage="to be a valid JSON object")] + public static . IsValidJsonObject(this string value) { } + } +} +namespace . { public class CountWrapper : ., . where TCollection : . @@ -2846,6 +2900,138 @@ namespace .Extensions public static . IsNotDefault(this . source) where TValue : class { } } + public static class JsonElementAssertionExtensions + { + public static ._DoesNotHaveProperty_String_Assertion DoesNotHaveProperty(this .<.> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._HasProperty_String_Assertion HasProperty(this .<.> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._IsArray_Assertion IsArray(this .<.> source) { } + public static ._IsBoolean_Assertion IsBoolean(this .<.> source) { } + public static ._IsNotNull_Assertion IsNotNull(this .<.> source) { } + public static ._IsNull_Assertion IsNull(this .<.> source) { } + public static ._IsNumber_Assertion IsNumber(this .<.> source) { } + public static ._IsObject_Assertion IsObject(this .<.> source) { } + public static ._IsString_Assertion IsString(this .<.> source) { } + } + public sealed class JsonElement_DoesNotHaveProperty_String_Assertion : .<.> + { + public JsonElement_DoesNotHaveProperty_String_Assertion(.<.> context, string propertyName) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_HasProperty_String_Assertion : .<.> + { + public JsonElement_HasProperty_String_Assertion(.<.> context, string propertyName) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsArray_Assertion : .<.> + { + public JsonElement_IsArray_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsBoolean_Assertion : .<.> + { + public JsonElement_IsBoolean_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNotNull_Assertion : .<.> + { + public JsonElement_IsNotNull_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNull_Assertion : .<.> + { + public JsonElement_IsNull_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNumber_Assertion : .<.> + { + public JsonElement_IsNumber_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsObject_Assertion : .<.> + { + public JsonElement_IsObject_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsString_Assertion : .<.> + { + public JsonElement_IsString_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public static class JsonNodeAssertionExtensions + { + public static ._DoesNotHaveJsonProperty_String_Assertion DoesNotHaveJsonProperty(this .<..JsonNode?> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._HasJsonArrayCount_Int_Assertion HasJsonArrayCount(this .<..JsonNode?> source, int expected, [.("expected")] string? expectedExpression = null) { } + public static ._HasJsonProperty_String_Assertion HasJsonProperty(this .<..JsonNode?> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._IsJsonArray_Assertion IsJsonArray(this .<..JsonNode?> source) { } + public static ._IsJsonArrayEmpty_Assertion IsJsonArrayEmpty(this .<..JsonNode?> source) { } + public static ._IsJsonArrayNotEmpty_Assertion IsJsonArrayNotEmpty(this .<..JsonNode?> source) { } + public static ._IsJsonObject_Assertion IsJsonObject(this .<..JsonNode?> source) { } + public static ._IsJsonValue_Assertion IsJsonValue(this .<..JsonNode?> source) { } + } + public sealed class JsonNode_DoesNotHaveJsonProperty_String_Assertion : .<..JsonNode?> + { + public JsonNode_DoesNotHaveJsonProperty_String_Assertion(.<..JsonNode?> context, string propertyName) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_HasJsonArrayCount_Int_Assertion : .<..JsonNode?> + { + public JsonNode_HasJsonArrayCount_Int_Assertion(.<..JsonNode?> context, int expected) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_HasJsonProperty_String_Assertion : .<..JsonNode?> + { + public JsonNode_HasJsonProperty_String_Assertion(.<..JsonNode?> context, string propertyName) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArrayEmpty_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArrayEmpty_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArrayNotEmpty_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArrayNotEmpty_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArray_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArray_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonObject_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonObject_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonValue_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonValue_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public static class JsonStringAssertionExtensions + { + public static ._IsNotValidJson_Assertion IsNotValidJson(this . source) { } + public static ._IsValidJson_Assertion IsValidJson(this . source) { } + public static ._IsValidJsonArray_Assertion IsValidJsonArray(this . source) { } + public static ._IsValidJsonObject_Assertion IsValidJsonObject(this . source) { } + } public static class LazyAssertionExtensions { public static ._IsValueCreated_Assertion IsValueCreated(this .<> source) { } @@ -3197,6 +3383,30 @@ namespace .Extensions public static . IsNullOrEmpty(this . source) { } public static . IsNullOrWhiteSpace(this . source) { } } + public sealed class String_IsNotValidJson_Assertion : . + { + public String_IsNotValidJson_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJsonArray_Assertion : . + { + public String_IsValidJsonArray_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJsonObject_Assertion : . + { + public String_IsValidJsonObject_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJson_Assertion : . + { + public String_IsValidJson_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } public sealed class T_IsIn_IEnumerableT_Assertion : . { public T_IsIn_IEnumerableT_Assertion(. context, . collection) { } diff --git a/docs/plans/2025-12-25-nunit-expectedresult-implementation.md b/docs/plans/2025-12-25-nunit-expectedresult-implementation.md deleted file mode 100644 index c27e59817e..0000000000 --- a/docs/plans/2025-12-25-nunit-expectedresult-implementation.md +++ /dev/null @@ -1,800 +0,0 @@ -# NUnit ExpectedResult Migration Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add code fixer support for migrating NUnit's `ExpectedResult` pattern to TUnit's assertion-based approach. - -**Architecture:** Extend the existing `NUnitMigrationCodeFixProvider` with a new `NUnitExpectedResultRewriter` that transforms `[TestCase(..., ExpectedResult = X)]` into `[Arguments(..., X)]` with an assertion in the method body. The rewriter runs in the `ApplyFrameworkSpecificConversions` hook before attribute conversion. - -**Tech Stack:** Roslyn CodeAnalysis, C# Syntax Rewriters, existing TUnit.Analyzers infrastructure. - ---- - -## Task 1: Add Failing Test for Simple ExpectedResult - -**Files:** -- Modify: `TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs` - -**Step 1: Write the failing test** - -Add this test method at the end of the test class (before the `ConfigureNUnitTest` methods): - -```csharp -[Test] -public async Task NUnit_ExpectedResult_Converted() -{ - await CodeFixer.VerifyCodeFixAsync( - """ - using NUnit.Framework; - - {|#0:public class MyClass|} - { - [TestCase(2, 3, ExpectedResult = 5)] - public int Add(int a, int b) => a + b; - } - """, - Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), - """ - using TUnit.Core; - using TUnit.Assertions; - using static TUnit.Assertions.Assert; - using TUnit.Assertions.Extensions; - - public class MyClass - { - [Test] - [Arguments(2, 3, 5)] - public async Task Add(int a, int b, int expected) - { - await Assert.That(a + b).IsEqualTo(expected); - } - } - """, - ConfigureNUnitTest - ); -} -``` - -**Step 2: Run test to verify it fails** - -Run: `dotnet test TUnit.Analyzers.Tests --filter "NUnit_ExpectedResult_Converted"` - -Expected: FAIL (code fixer doesn't handle ExpectedResult yet) - -**Step 3: Commit** - -```bash -git add TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs -git commit -m "test: add failing test for NUnit ExpectedResult migration" -``` - ---- - -## Task 2: Create NUnitExpectedResultRewriter Class - -**Files:** -- Create: `TUnit.Analyzers.CodeFixers/NUnitExpectedResultRewriter.cs` - -**Step 1: Create the rewriter skeleton** - -```csharp -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace TUnit.Analyzers.CodeFixers; - -/// -/// Transforms NUnit [TestCase(..., ExpectedResult = X)] patterns to TUnit assertions. -/// -public class NUnitExpectedResultRewriter : CSharpSyntaxRewriter -{ - private readonly SemanticModel _semanticModel; - - public NUnitExpectedResultRewriter(SemanticModel semanticModel) - { - _semanticModel = semanticModel; - } - - public override SyntaxNode? VisitMethodDeclaration(MethodDeclarationSyntax node) - { - // Check if method has any TestCase attributes with ExpectedResult - var testCaseAttributes = GetTestCaseAttributesWithExpectedResult(node); - - if (testCaseAttributes.Count == 0) - { - return base.VisitMethodDeclaration(node); - } - - // Get the return type (will become the expected parameter type) - var returnType = node.ReturnType; - if (returnType is PredefinedTypeSyntax predefined && predefined.Keyword.IsKind(SyntaxKind.VoidKeyword)) - { - // void methods can't have ExpectedResult - return base.VisitMethodDeclaration(node); - } - - // Transform the method - return TransformMethod(node, testCaseAttributes, returnType); - } - - private List GetTestCaseAttributesWithExpectedResult(MethodDeclarationSyntax method) - { - var result = new List(); - - foreach (var attributeList in method.AttributeLists) - { - foreach (var attribute in attributeList.Attributes) - { - var name = attribute.Name.ToString(); - if (name is "TestCase" or "NUnit.Framework.TestCase" or "TestCaseAttribute" or "NUnit.Framework.TestCaseAttribute") - { - if (HasExpectedResultArgument(attribute)) - { - result.Add(attribute); - } - } - } - } - - return result; - } - - private bool HasExpectedResultArgument(AttributeSyntax attribute) - { - if (attribute.ArgumentList == null) - { - return false; - } - - return attribute.ArgumentList.Arguments - .Any(arg => arg.NameEquals?.Name.Identifier.Text == "ExpectedResult"); - } - - private MethodDeclarationSyntax TransformMethod( - MethodDeclarationSyntax method, - List testCaseAttributes, - TypeSyntax originalReturnType) - { - // 1. Add 'expected' parameter - var expectedParam = SyntaxFactory.Parameter(SyntaxFactory.Identifier("expected")) - .WithType(originalReturnType.WithTrailingTrivia(SyntaxFactory.Space)); - - var newParameters = method.ParameterList.AddParameters(expectedParam); - - // 2. Change return type to async Task - var asyncTaskType = SyntaxFactory.ParseTypeName("async Task ") - .WithTrailingTrivia(SyntaxFactory.Space); - - // 3. Transform the body - var newBody = TransformBody(method, originalReturnType); - - // 4. Build the new method - var newMethod = method - .WithReturnType(SyntaxFactory.ParseTypeName("Task").WithTrailingTrivia(SyntaxFactory.Space)) - .WithParameterList(newParameters) - .WithBody(newBody) - .WithExpressionBody(null) - .WithSemicolonToken(default); - - // 5. Add async modifier if not present - if (!method.Modifiers.Any(SyntaxKind.AsyncKeyword)) - { - var asyncModifier = SyntaxFactory.Token(SyntaxKind.AsyncKeyword).WithTrailingTrivia(SyntaxFactory.Space); - newMethod = newMethod.AddModifiers(asyncModifier); - } - - // 6. Update attribute lists (remove ExpectedResult from TestCase, add [Test]) - var newAttributeLists = TransformAttributeLists(method.AttributeLists, testCaseAttributes); - newMethod = newMethod.WithAttributeLists(newAttributeLists); - - return newMethod; - } - - private BlockSyntax TransformBody(MethodDeclarationSyntax method, TypeSyntax returnType) - { - ExpressionSyntax returnExpression; - - if (method.ExpressionBody != null) - { - // Expression-bodied: => a + b - returnExpression = method.ExpressionBody.Expression; - } - else if (method.Body != null) - { - // Block body - find return statements - var returnStatements = method.Body.Statements - .OfType() - .ToList(); - - if (returnStatements.Count == 1 && returnStatements[0].Expression != null) - { - // Single return - use the expression directly - returnExpression = returnStatements[0].Expression; - - // Build new body with all statements except the return, plus assertion - var statementsWithoutReturn = method.Body.Statements - .Where(s => s != returnStatements[0]) - .ToList(); - - var assertStatement = CreateAssertStatement(returnExpression); - statementsWithoutReturn.Add(assertStatement); - - return SyntaxFactory.Block(statementsWithoutReturn); - } - else if (returnStatements.Count > 1) - { - // Multiple returns - use local variable pattern - return TransformMultipleReturns(method.Body, returnType); - } - else - { - // No return found - shouldn't happen for ExpectedResult - return method.Body; - } - } - else - { - // No body - shouldn't happen - return SyntaxFactory.Block(); - } - - // For expression-bodied, create block with assertion - var assertion = CreateAssertStatement(returnExpression); - return SyntaxFactory.Block(assertion); - } - - private BlockSyntax TransformMultipleReturns(BlockSyntax body, TypeSyntax returnType) - { - // Declare: {returnType} result; - var resultDeclaration = SyntaxFactory.LocalDeclarationStatement( - SyntaxFactory.VariableDeclaration(returnType.WithTrailingTrivia(SyntaxFactory.Space)) - .WithVariables(SyntaxFactory.SingletonSeparatedList( - SyntaxFactory.VariableDeclarator("result")))); - - // Replace each 'return X;' with 'result = X;' - var rewriter = new ReturnToAssignmentRewriter(); - var transformedBody = (BlockSyntax)rewriter.Visit(body); - - // Add result declaration at start - var statements = new List { resultDeclaration }; - statements.AddRange(transformedBody.Statements); - - // Add assertion at end - var assertStatement = CreateAssertStatement( - SyntaxFactory.IdentifierName("result")); - statements.Add(assertStatement); - - return SyntaxFactory.Block(statements); - } - - private ExpressionStatementSyntax CreateAssertStatement(ExpressionSyntax actualExpression) - { - // await Assert.That(actualExpression).IsEqualTo(expected); - var assertThat = SyntaxFactory.InvocationExpression( - SyntaxFactory.MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - SyntaxFactory.IdentifierName("Assert"), - SyntaxFactory.IdentifierName("That")), - SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( - SyntaxFactory.Argument(actualExpression)))); - - var isEqualTo = SyntaxFactory.InvocationExpression( - SyntaxFactory.MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - assertThat, - SyntaxFactory.IdentifierName("IsEqualTo")), - SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( - SyntaxFactory.Argument(SyntaxFactory.IdentifierName("expected"))))); - - var awaitExpr = SyntaxFactory.AwaitExpression(isEqualTo); - - return SyntaxFactory.ExpressionStatement(awaitExpr); - } - - private SyntaxList TransformAttributeLists( - SyntaxList attributeLists, - List testCaseAttributes) - { - var result = new List(); - bool hasTestAttribute = false; - - foreach (var attrList in attributeLists) - { - var newAttributes = new List(); - - foreach (var attr in attrList.Attributes) - { - var name = attr.Name.ToString(); - - if (name is "Test" or "NUnit.Framework.Test") - { - hasTestAttribute = true; - newAttributes.Add(attr); - } - else if (testCaseAttributes.Contains(attr)) - { - // Transform TestCase with ExpectedResult to Arguments - var transformed = TransformTestCaseAttribute(attr); - newAttributes.Add(transformed); - } - else - { - newAttributes.Add(attr); - } - } - - if (newAttributes.Count > 0) - { - result.Add(attrList.WithAttributes(SyntaxFactory.SeparatedList(newAttributes))); - } - } - - // Add [Test] attribute if not present - if (!hasTestAttribute) - { - var testAttr = SyntaxFactory.Attribute(SyntaxFactory.IdentifierName("Test")); - var testAttrList = SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(testAttr)) - .WithLeadingTrivia(attributeLists.First().GetLeadingTrivia()); - result.Insert(0, testAttrList); - } - - return SyntaxFactory.List(result); - } - - private AttributeSyntax TransformTestCaseAttribute(AttributeSyntax attribute) - { - if (attribute.ArgumentList == null) - { - return attribute; - } - - var newArgs = new List(); - ExpressionSyntax? expectedValue = null; - - foreach (var arg in attribute.ArgumentList.Arguments) - { - if (arg.NameEquals?.Name.Identifier.Text == "ExpectedResult") - { - expectedValue = arg.Expression; - } - else if (arg.NameColon == null && arg.NameEquals == null) - { - // Positional argument - keep it - newArgs.Add(arg); - } - // Skip other named arguments for now - } - - // Add expected value as last positional argument - if (expectedValue != null) - { - newArgs.Add(SyntaxFactory.AttributeArgument(expectedValue)); - } - - // The attribute will be renamed to "Arguments" by the existing attribute rewriter - return attribute.WithArgumentList( - SyntaxFactory.AttributeArgumentList(SyntaxFactory.SeparatedList(newArgs))); - } - - private class ReturnToAssignmentRewriter : CSharpSyntaxRewriter - { - public override SyntaxNode? VisitReturnStatement(ReturnStatementSyntax node) - { - if (node.Expression == null) - { - return node; - } - - // return X; -> result = X; - return SyntaxFactory.ExpressionStatement( - SyntaxFactory.AssignmentExpression( - SyntaxKind.SimpleAssignmentExpression, - SyntaxFactory.IdentifierName("result"), - node.Expression)); - } - } -} -``` - -**Step 2: Build to verify no syntax errors** - -Run: `dotnet build TUnit.Analyzers.CodeFixers` - -Expected: Build succeeded - -**Step 3: Commit** - -```bash -git add TUnit.Analyzers.CodeFixers/NUnitExpectedResultRewriter.cs -git commit -m "feat: add NUnitExpectedResultRewriter skeleton" -``` - ---- - -## Task 3: Integrate Rewriter into Code Fix Provider - -**Files:** -- Modify: `TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs` - -**Step 1: Update ApplyFrameworkSpecificConversions** - -Replace the `ApplyFrameworkSpecificConversions` method: - -```csharp -protected override CompilationUnitSyntax ApplyFrameworkSpecificConversions(CompilationUnitSyntax compilationUnit, SemanticModel semanticModel, Compilation compilation) -{ - // Transform ExpectedResult patterns before attribute conversion - var expectedResultRewriter = new NUnitExpectedResultRewriter(semanticModel); - compilationUnit = (CompilationUnitSyntax)expectedResultRewriter.Visit(compilationUnit); - - return compilationUnit; -} -``` - -**Step 2: Run the test** - -Run: `dotnet test TUnit.Analyzers.Tests --filter "NUnit_ExpectedResult_Converted"` - -Expected: May pass or fail depending on edge cases - we'll iterate - -**Step 3: Commit** - -```bash -git add TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs -git commit -m "feat: integrate ExpectedResult rewriter into NUnit migration" -``` - ---- - -## Task 4: Fix Formatting Issues - -**Files:** -- Modify: `TUnit.Analyzers.CodeFixers/NUnitExpectedResultRewriter.cs` - -**Step 1: Run test and check output** - -Run: `dotnet test TUnit.Analyzers.Tests --filter "NUnit_ExpectedResult_Converted" -v n` - -Examine the actual output vs expected - likely issues with: -- Trivia (whitespace, newlines) -- Attribute ordering -- Method modifier ordering - -**Step 2: Fix identified issues** - -Common fixes needed: -- Add proper leading/trailing trivia to statements -- Ensure async modifier is in correct position -- Fix attribute list formatting - -**Step 3: Run test again** - -Run: `dotnet test TUnit.Analyzers.Tests --filter "NUnit_ExpectedResult_Converted"` - -Expected: PASS - -**Step 4: Commit** - -```bash -git add TUnit.Analyzers.CodeFixers/NUnitExpectedResultRewriter.cs -git commit -m "fix: correct formatting in ExpectedResult transformation" -``` - ---- - -## Task 5: Add Test for Multiple TestCase Attributes - -**Files:** -- Modify: `TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs` - -**Step 1: Write the test** - -```csharp -[Test] -public async Task NUnit_Multiple_ExpectedResult_Converted() -{ - await CodeFixer.VerifyCodeFixAsync( - """ - using NUnit.Framework; - - {|#0:public class MyClass|} - { - [TestCase(2, 3, ExpectedResult = 5)] - [TestCase(10, 5, ExpectedResult = 15)] - [TestCase(0, 0, ExpectedResult = 0)] - public int Add(int a, int b) => a + b; - } - """, - Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), - """ - using TUnit.Core; - using TUnit.Assertions; - using static TUnit.Assertions.Assert; - using TUnit.Assertions.Extensions; - - public class MyClass - { - [Test] - [Arguments(2, 3, 5)] - [Arguments(10, 5, 15)] - [Arguments(0, 0, 0)] - public async Task Add(int a, int b, int expected) - { - await Assert.That(a + b).IsEqualTo(expected); - } - } - """, - ConfigureNUnitTest - ); -} -``` - -**Step 2: Run test** - -Run: `dotnet test TUnit.Analyzers.Tests --filter "NUnit_Multiple_ExpectedResult_Converted"` - -Expected: PASS - -**Step 3: Commit** - -```bash -git add TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs -git commit -m "test: add test for multiple TestCase with ExpectedResult" -``` - ---- - -## Task 6: Add Test for Block-Bodied Method with Single Return - -**Files:** -- Modify: `TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs` - -**Step 1: Write the test** - -```csharp -[Test] -public async Task NUnit_ExpectedResult_BlockBody_SingleReturn_Converted() -{ - await CodeFixer.VerifyCodeFixAsync( - """ - using NUnit.Framework; - - {|#0:public class MyClass|} - { - [TestCase(2, 3, ExpectedResult = 5)] - public int Add(int a, int b) - { - var sum = a + b; - return sum; - } - } - """, - Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), - """ - using TUnit.Core; - using TUnit.Assertions; - using static TUnit.Assertions.Assert; - using TUnit.Assertions.Extensions; - - public class MyClass - { - [Test] - [Arguments(2, 3, 5)] - public async Task Add(int a, int b, int expected) - { - var sum = a + b; - await Assert.That(sum).IsEqualTo(expected); - } - } - """, - ConfigureNUnitTest - ); -} -``` - -**Step 2: Run test** - -Run: `dotnet test TUnit.Analyzers.Tests --filter "NUnit_ExpectedResult_BlockBody_SingleReturn_Converted"` - -Expected: PASS (or fix if needed) - -**Step 3: Commit** - -```bash -git add TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs -git commit -m "test: add test for block-bodied ExpectedResult with single return" -``` - ---- - -## Task 7: Add Test for Multiple Return Statements - -**Files:** -- Modify: `TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs` - -**Step 1: Write the test** - -```csharp -[Test] -public async Task NUnit_ExpectedResult_MultipleReturns_Converted() -{ - await CodeFixer.VerifyCodeFixAsync( - """ - using NUnit.Framework; - - {|#0:public class MyClass|} - { - [TestCase(-1, ExpectedResult = 0)] - [TestCase(0, ExpectedResult = 1)] - [TestCase(5, ExpectedResult = 120)] - public int Factorial(int n) - { - if (n < 0) return 0; - if (n <= 1) return 1; - return n * Factorial(n - 1); - } - } - """, - Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), - """ - using TUnit.Core; - using TUnit.Assertions; - using static TUnit.Assertions.Assert; - using TUnit.Assertions.Extensions; - - public class MyClass - { - [Test] - [Arguments(-1, 0)] - [Arguments(0, 1)] - [Arguments(5, 120)] - public async Task Factorial(int n, int expected) - { - int result; - if (n < 0) result = 0; - else if (n <= 1) result = 1; - else result = n * Factorial(n - 1); - await Assert.That(result).IsEqualTo(expected); - } - } - """, - ConfigureNUnitTest - ); -} -``` - -**Step 2: Run test** - -Run: `dotnet test TUnit.Analyzers.Tests --filter "NUnit_ExpectedResult_MultipleReturns_Converted"` - -Expected: May fail initially - multiple returns require more complex transformation - -**Step 3: Fix if needed and commit** - -```bash -git add TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs -git commit -m "test: add test for ExpectedResult with multiple returns" -``` - ---- - -## Task 8: Add Test for String ExpectedResult - -**Files:** -- Modify: `TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs` - -**Step 1: Write the test** - -```csharp -[Test] -public async Task NUnit_ExpectedResult_String_Converted() -{ - await CodeFixer.VerifyCodeFixAsync( - """ - using NUnit.Framework; - - {|#0:public class MyClass|} - { - [TestCase("hello", ExpectedResult = "HELLO")] - [TestCase("World", ExpectedResult = "WORLD")] - public string ToUpper(string input) => input.ToUpper(); - } - """, - Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), - """ - using TUnit.Core; - using TUnit.Assertions; - using static TUnit.Assertions.Assert; - using TUnit.Assertions.Extensions; - - public class MyClass - { - [Test] - [Arguments("hello", "HELLO")] - [Arguments("World", "WORLD")] - public async Task ToUpper(string input, string expected) - { - await Assert.That(input.ToUpper()).IsEqualTo(expected); - } - } - """, - ConfigureNUnitTest - ); -} -``` - -**Step 2: Run test** - -Run: `dotnet test TUnit.Analyzers.Tests --filter "NUnit_ExpectedResult_String_Converted"` - -Expected: PASS - -**Step 3: Commit** - -```bash -git add TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs -git commit -m "test: add test for string ExpectedResult migration" -``` - ---- - -## Task 9: Run Full Test Suite - -**Files:** None (verification only) - -**Step 1: Run all NUnit migration tests** - -Run: `dotnet test TUnit.Analyzers.Tests --filter "NUnitMigration"` - -Expected: All tests PASS - -**Step 2: Run full analyzer test suite** - -Run: `dotnet test TUnit.Analyzers.Tests` - -Expected: All tests PASS - -**Step 3: Commit any remaining fixes** - -```bash -git add -A -git commit -m "fix: address any remaining test failures" -``` - ---- - -## Task 10: Update Design Document Status - -**Files:** -- Modify: `docs/plans/2025-12-25-nunit-expectedresult-migration-design.md` - -**Step 1: Update status** - -Change the Status line from: -``` -**Status**: Design Complete -``` - -To: -``` -**Status**: Implemented -``` - -**Step 2: Commit** - -```bash -git add docs/plans/2025-12-25-nunit-expectedresult-migration-design.md -git commit -m "docs: mark ExpectedResult migration as implemented" -``` - ---- - -## Verification Checklist - -After all tasks complete: - -- [ ] `dotnet test TUnit.Analyzers.Tests` passes -- [ ] `dotnet build TUnit.Analyzers.CodeFixers` succeeds -- [ ] New tests cover: simple ExpectedResult, multiple TestCase, block body, multiple returns, string types -- [ ] Code follows existing patterns in NUnitMigrationCodeFixProvider diff --git a/docs/plans/2025-12-25-nunit-expectedresult-migration-design.md b/docs/plans/2025-12-25-nunit-expectedresult-migration-design.md deleted file mode 100644 index 6fc8da8aeb..0000000000 --- a/docs/plans/2025-12-25-nunit-expectedresult-migration-design.md +++ /dev/null @@ -1,215 +0,0 @@ -# NUnit ExpectedResult Migration Code Fixer - -**Issue**: #4167 -**Date**: 2025-12-25 -**Status**: Implemented - -## Overview - -A Roslyn analyzer + code fixer that extends TUnit's existing NUnit migration infrastructure to handle `ExpectedResult` patterns. Converts NUnit's return-value-based test assertions to TUnit's explicit assertion approach. - -## Scope - -### Patterns Handled - -1. `[TestCase(..., ExpectedResult = X)]` - Inline expected result property -2. `TestCaseData.Returns(X)` - Fluent expected result in data sources - -### Patterns Flagged for Manual Review - -- Mixed attributes (some with ExpectedResult, some without) -- `TestCaseData` with `.SetName()`, `.SetCategory()`, or other chained methods -- Dynamic/computed `TestCaseData` (loops, conditionals) -- `TestCaseData` constructed outside simple array/collection initializers - -## Transformation Examples - -### TestCase with ExpectedResult - -```csharp -// BEFORE (NUnit) -[TestCase(2, 3, ExpectedResult = 5)] -[TestCase(10, 5, ExpectedResult = 15)] -public int Add(int a, int b) => a + b; - -// AFTER (TUnit) -[Test] -[Arguments(2, 3, 5)] -[Arguments(10, 5, 15)] -public async Task Add(int a, int b, int expected) -{ - await Assert.That(a + b).IsEqualTo(expected); -} -``` - -### TestCaseData.Returns() - -```csharp -// BEFORE (NUnit) -public static IEnumerable AddCases => new[] -{ - new TestCaseData(2, 3).Returns(5), - new TestCaseData(10, 5).Returns(15) -}; - -[TestCaseSource(nameof(AddCases))] -public int Add(int a, int b) => a + b; - -// AFTER (TUnit) -public static IEnumerable<(int, int, int)> AddCases => new[] -{ - (2, 3, 5), - (10, 5, 15) -}; - -[Test] -[MethodDataSource(nameof(AddCases))] -public async Task Add(int a, int b, int expected) -{ - await Assert.That(a + b).IsEqualTo(expected); -} -``` - -## Design Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Multiple ExpectedResults | Add as method parameter | Preserves parameterized structure, single method | -| Expression-bodied members | Convert to block body | Semantic change warrants explicit block | -| Multiple return statements | Extract to local variable | Cleaner code, single assertion point | -| Null expected values | Use `IsEqualTo(expected)` | Parameter value unknown at compile-time | -| Parameter naming | `expected` | Concise, idiomatic in testing | - -## Method Body Transformation - -### Case A: Expression-Bodied - -```csharp -// BEFORE -public int Add(int a, int b) => a + b; - -// AFTER -public async Task Add(int a, int b, int expected) -{ - await Assert.That(a + b).IsEqualTo(expected); -} -``` - -### Case B: Single Return - -```csharp -// BEFORE -public int Add(int a, int b) -{ - var sum = a + b; - return sum; -} - -// AFTER -public async Task Add(int a, int b, int expected) -{ - var sum = a + b; - await Assert.That(sum).IsEqualTo(expected); -} -``` - -### Case C: Multiple Returns - -```csharp -// BEFORE -public int Factorial(int n) -{ - if (n < 0) return 0; - if (n <= 1) return 1; - return n * Factorial(n - 1); -} - -// AFTER -public async Task Factorial(int n, int expected) -{ - int result; - if (n < 0) result = 0; - else if (n <= 1) result = 1; - else result = n * Factorial(n - 1); - await Assert.That(result).IsEqualTo(expected); -} -``` - -**Algorithm for multiple returns**: -1. Declare `{returnType} result;` at method start -2. Replace each `return X;` with `result = X;` -3. Convert `if (...) return` chains to `if/else if/else` -4. Append `await Assert.That(result).IsEqualTo(expected);` at end - -## Implementation - -### Analyzer - -Extend `NUnitMigrationAnalyzer` to detect: -- `ExpectedResult` named argument in `[TestCase]` attributes -- `.Returns()` method calls on `TestCaseData` - -**Diagnostic**: `TUnit0050` (Info severity) -**Message**: "NUnit ExpectedResult can be converted to TUnit assertion" - -### Code Fixer - -#### New Files - -``` -TUnit.Analyzers.CodeFixers/ -├── NUnitExpectedResultRewriter.cs # TestCase ExpectedResult transform -└── NUnitTestCaseDataRewriter.cs # TestCaseData.Returns() transform -``` - -#### Integration - -Add to `NUnitMigrationCodeFixProvider.cs` transformation pipeline: - -```csharp -// Before attribute conversion: -root = new NUnitExpectedResultRewriter(semanticModel).Visit(root); -root = new NUnitTestCaseDataRewriter(semanticModel).Visit(root); -``` - -### TestCase Transformation Steps - -1. Extract `ExpectedResult` values from each `[TestCase]` -2. Convert `[TestCase(args, ExpectedResult = X)]` → `[Arguments(args, X)]` -3. Add `expected` parameter with original return type -4. Change return type to `async Task` -5. Transform method body per cases above -6. Add `[Test]` attribute if not present - -### TestCaseData Transformation Steps - -1. Locate data source method/property -2. For each `TestCaseData`: extract args + `.Returns()` value → tuple -3. Update return type: `IEnumerable` → `IEnumerable<(T1, T2, ..., TExpected)>` -4. Transform test method same as TestCase - -## Testing - -Add to `TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs`: - -- Simple ExpectedResult (single TestCase) -- Multiple TestCase attributes with different expected values -- Expression-bodied methods -- Block-bodied with single return -- Block-bodied with multiple returns -- Null expected values -- Reference type expected values -- TestCaseData.Returns() simple case -- TestCaseData.Returns() with multiple entries -- Mixed scenarios (flagged for manual review) - -## Edge Cases - -| Scenario | Handling | -|----------|----------| -| Null ExpectedResult | `IsEqualTo(expected)` handles null at runtime | -| Constant references (`int.MaxValue`) | Passed through as parameter value | -| Generic return types | Parameter type matches original return type | -| Nullable return types | Parameter type includes nullability | -| Recursive methods | Works - method signature change is compatible | -| Mixed TestCase (with/without ExpectedResult) | Flag for manual review | diff --git a/docs/plans/2025-12-26-lock-contention-optimization-design.md b/docs/plans/2025-12-26-lock-contention-optimization-design.md deleted file mode 100644 index 010cb0313f..0000000000 --- a/docs/plans/2025-12-26-lock-contention-optimization-design.md +++ /dev/null @@ -1,377 +0,0 @@ -# Lock Contention Optimization Design - -**Issue:** [#4162 - perf: reduce lock contention in test discovery and scheduling](https://github.com/thomhurst/TUnit/issues/4162) -**Date:** 2025-12-26 -**Priority:** P1 -**Goal:** Maximum parallelism - minimize lock duration at any cost - ---- - -## Problem Statement - -Two performance-critical code paths unnecessarily extend lock durations, creating bottlenecks in parallel test execution: - -1. **ReflectionTestDataCollector.cs (lines 137-141):** Lock remains held while creating a full defensive copy of discovered tests -2. **ConstraintKeyScheduler.cs (lines 56-69, 151-190):** LINQ evaluation and list allocation happen within lock scope - -These practices serialize operations that should execute in parallel, degrading throughput on multi-core systems. - ---- - -## Solution Overview - -| Component | Approach | Complexity | -|-----------|----------|------------| -| ReflectionTestDataCollector | ImmutableList with atomic swap | Medium | -| ConstraintKeyScheduler | Manual loops + two-phase locking + pre-allocation | High | - ---- - -## Design: ReflectionTestDataCollector - -### Current Implementation (Problem) - -```csharp -private static readonly List _discoveredTests = new(capacity: 1000); -private static readonly Lock _discoveredTestsLock = new(); - -public async Task> CollectTestsAsync(string testSessionId) -{ - // ... discovery logic ... - - lock (_discoveredTestsLock) - { - _discoveredTests.AddRange(newTests); - return new List(_discoveredTests); // O(n) copy under lock - } -} -``` - -### Proposed Implementation - -Replace `List` + lock with `ImmutableList` + atomic swap: - -```csharp -private static ImmutableList _discoveredTests = ImmutableList.Empty; - -public async Task> CollectTestsAsync(string testSessionId) -{ - // ... discovery logic unchanged ... - - // Atomic swap - no lock needed for readers - ImmutableList original, updated; - do - { - original = _discoveredTests; - updated = original.AddRange(newTests); - } while (Interlocked.CompareExchange(ref _discoveredTests, updated, original) != original); - - return _discoveredTests; // Already immutable, no copy needed -} -``` - -For streaming writes, use `ImmutableInterlocked.Update()` helper: - -```csharp -// In CollectTestsStreamingAsync -ImmutableInterlocked.Update(ref _discoveredTests, list => list.Add(test)); -yield return test; -``` - -### Benefits - -- Zero lock contention on reads (callers get immutable snapshot) -- Writes use lock-free CAS loop (brief spin during concurrent writes) -- Eliminates defensive copy entirely -- Thread-safe enumeration without locks - -### Trade-offs - -- Slightly higher allocation on writes (new immutable list per update) -- Discovery is infrequent compared to reads, so this is acceptable - ---- - -## Design: ConstraintKeyScheduler - -### Problem 1: LINQ Inside Lock (lines 56-69) - -**Current:** -```csharp -lock (lockObject) -{ - canStart = !constraintKeys.Any(key => lockedKeys.Contains(key)); // LINQ allocation - if (canStart) - { - foreach (var key in constraintKeys) - lockedKeys.Add(key); - } -} -``` - -**Proposed - Manual loop with indexer access:** -```csharp -lock (lockObject) -{ - canStart = true; - var keyCount = constraintKeys.Count; - for (var i = 0; i < keyCount; i++) - { - if (lockedKeys.Contains(constraintKeys[i])) - { - canStart = false; - break; // Early exit - } - } - - if (canStart) - { - for (var i = 0; i < keyCount; i++) - lockedKeys.Add(constraintKeys[i]); - } -} -``` - -**Benefits:** -- No delegate allocation -- No enumerator allocation -- Early exit on first conflict - -### Problem 2: Allocations and Extended Lock Scope (lines 149-190) - -**Current:** -```csharp -var testsToStart = new List<...>(); // Outside lock - good - -lock (lockObject) -{ - foreach (var key in constraintKeys) - lockedKeys.Remove(key); - - var tempQueue = new List<...>(); // Allocation INSIDE lock - bad - - while (waitingTests.TryDequeue(out var waitingTest)) - { - var canStart = !waitingTest.ConstraintKeys.Any(...); // LINQ inside lock - // ... extensive work inside lock - } - - foreach (var item in tempQueue) - waitingTests.Enqueue(item); -} -``` - -**Proposed - Two-phase locking with pre-allocation:** - -```csharp -// Pre-allocate outside any lock -var testsToStart = new List<(..., TaskCompletionSource)>(4); -var testsToRequeue = new List<(..., TaskCompletionSource)>(8); -var waitingSnapshot = new List<(..., TaskCompletionSource)>(8); - -// Phase 1: Release keys and snapshot queue (single brief lock) -lock (lockObject) -{ - var keyCount = constraintKeys.Count; - for (var i = 0; i < keyCount; i++) - lockedKeys.Remove(constraintKeys[i]); - - while (waitingTests.TryDequeue(out var item)) - waitingSnapshot.Add(item); -} - -// Phase 2: For each candidate, try to acquire keys (brief lock per candidate) -foreach (var waitingTest in waitingSnapshot) -{ - bool acquired; - lock (lockObject) - { - acquired = true; - var keys = waitingTest.ConstraintKeys; - var keyCount = keys.Count; - for (var i = 0; i < keyCount; i++) - { - if (lockedKeys.Contains(keys[i])) - { - acquired = false; - break; - } - } - - if (acquired) - { - for (var i = 0; i < keyCount; i++) - lockedKeys.Add(keys[i]); - } - } - - if (acquired) - testsToStart.Add(waitingTest); - else - testsToRequeue.Add(waitingTest); -} - -// Phase 3: Requeue non-starters (single brief lock) -if (testsToRequeue.Count > 0) -{ - lock (lockObject) - { - foreach (var item in testsToRequeue) - waitingTests.Enqueue(item); - } -} - -// Phase 4: Signal starters (outside lock - no contention) -foreach (var test in testsToStart) - test.StartSignal.SetResult(true); -``` - -### Benefits - -- Multiple brief locks instead of one long lock -- Other threads can interleave between phases -- All allocations outside locks -- No LINQ allocations -- Early exit in availability checks - ---- - -## Testing Strategy - -### 1. Stress Tests for Thread Safety - -Add to `TUnit.Engine.Tests`: - -```csharp -[Test] -[Repeat(10)] -public async Task ReflectionTestDataCollector_ConcurrentDiscovery_NoRaceConditions() -{ - var tasks = Enumerable.Range(0, 50) - .Select(_ => collector.CollectTestsAsync(Guid.NewGuid().ToString())); - - var results = await Task.WhenAll(tasks); - - await Assert.That(results.SelectMany(r => r).Distinct().Count()) - .IsGreaterThan(0); -} - -[Test] -[Repeat(10)] -public async Task ConstraintKeyScheduler_HighContention_NoDeadlocks() -{ - var tests = CreateTestsWithOverlappingConstraints(100, overlapFactor: 0.3); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - await scheduler.ExecuteTestsWithConstraintsAsync(tests, cts.Token); -} -``` - -### 2. Wall Clock Benchmarks - -```csharp -[Test] -[Category("Performance")] -public async Task Benchmark_TestDiscovery_WallClock() -{ - var stopwatch = Stopwatch.StartNew(); - var tests = await collector.CollectTestsAsync(testSessionId); - stopwatch.Stop(); - - Console.WriteLine($"Discovery wall clock: {stopwatch.ElapsedMilliseconds}ms"); - Console.WriteLine($"Tests discovered: {tests.Count()}"); - Console.WriteLine($"Throughput: {tests.Count() / stopwatch.Elapsed.TotalSeconds:F0} tests/sec"); -} - -[Test] -[Category("Performance")] -public async Task Benchmark_ConstrainedExecution_WallClock() -{ - var tests = CreateTestsWithConstraints(count: 500, constraintOverlap: 0.3); - - var stopwatch = Stopwatch.StartNew(); - await scheduler.ExecuteTestsWithConstraintsAsync(tests, CancellationToken.None); - stopwatch.Stop(); - - Console.WriteLine($"Constrained execution wall clock: {stopwatch.ElapsedMilliseconds}ms"); -} -``` - -### 3. Profiling with dotnet-trace - -```bash -# Baseline (before changes) -dotnet trace collect --name TUnit.PerformanceTests -- dotnet run -c Release -mv trace.nettrace baseline.nettrace - -# Optimized (after changes) -dotnet trace collect --name TUnit.PerformanceTests -- dotnet run -c Release -mv trace.nettrace optimized.nettrace -``` - -### 4. Key Metrics - -| Scenario | Metric | Target | -|----------|--------|--------| -| Discovery (1000 tests) | Wall clock time | >=10% improvement | -| Constrained execution (500 tests, 30% overlap) | Wall clock time | >=15% improvement | -| High parallelism (16+ cores) | Scaling efficiency | Near-linear | -| Lock contention | Thread wait time | >=50% reduction | -| Memory | Hot path allocations | >=30% reduction | - ---- - -## Risk Mitigation - -### Race Condition Risks - -| Risk | Mitigation | -|------|------------| -| ImmutableList CAS loop starvation | Add retry limit with fallback to lock; contention is rare during discovery | -| Lost updates in two-phase lock | Each phase is atomic; tests either start or requeue, never lost | -| Stale reads of `_discoveredTests` | Acceptable - immutable snapshots are always consistent | - -### Behavioral Compatibility - -| Concern | Mitigation | -|---------|------------| -| Test ordering changes | Discovery order was never guaranteed | -| API consumers expecting mutable list | Return type is `IEnumerable`, already read-only contract | -| Constraint scheduling order | Priority ordering preserved | - -### Rollback Strategy - -Both changes are isolated: -- `ReflectionTestDataCollector`: Revert to `List` + lock with single file change -- `ConstraintKeyScheduler`: Revert loop-by-loop if specific optimization causes issues - ---- - -## Implementation Plan - -### Incremental Rollout (Suggested Merge Order) - -1. **PR 1: LINQ to manual loop replacements** (lowest risk) - - Replace `.Any()` with manual loops in ConstraintKeyScheduler - - Immediate benefit, minimal code change - -2. **PR 2: Lock scope restructuring** (medium risk) - - Two-phase locking in ConstraintKeyScheduler - - Pre-allocate lists outside locks - -3. **PR 3: ImmutableList migration** (medium risk) - - Replace List + lock with ImmutableList in ReflectionTestDataCollector - - Atomic swap pattern - -This allows isolating any regressions to specific changes. - ---- - -## Verification Checklist - -- [ ] All existing tests pass -- [ ] Stress tests added and passing -- [ ] Wall clock benchmarks show improvement -- [ ] dotnet-trace shows reduced lock contention -- [ ] No deadlocks under high parallelism (16+ cores) -- [ ] Memory allocations reduced in hot paths diff --git a/docs/plans/2025-12-26-lock-contention-optimization.md b/docs/plans/2025-12-26-lock-contention-optimization.md deleted file mode 100644 index fc88ab6335..0000000000 --- a/docs/plans/2025-12-26-lock-contention-optimization.md +++ /dev/null @@ -1,505 +0,0 @@ -# Lock Contention Optimization Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Reduce lock contention in test discovery and scheduling to improve parallel test execution throughput. - -**Architecture:** Replace `List` + lock with `ImmutableList` + atomic swap in ReflectionTestDataCollector. Replace LINQ with manual loops and restructure to two-phase locking in ConstraintKeyScheduler. - -**Tech Stack:** C#, System.Collections.Immutable, System.Threading - ---- - -## Task 1: Add Stress Tests for Thread Safety Baseline - -**Files:** -- Create: `TUnit.Engine.Tests/Scheduling/ConstraintKeySchedulerConcurrencyTests.cs` - -**Step 1: Write the failing test for high contention scenarios** - -```csharp -using TUnit.Core; -using TUnit.Engine.Scheduling; - -namespace TUnit.Engine.Tests.Scheduling; - -public class ConstraintKeySchedulerConcurrencyTests -{ - [Test] - [Repeat(5)] - public async Task HighContention_WithOverlappingConstraints_CompletesWithoutDeadlock() - { - // Arrange - create mock tests with overlapping constraint keys - // This test establishes baseline behavior before optimization - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - - // Act & Assert - should complete without timeout/deadlock - await Assert.That(async () => - { - // Placeholder - will be implemented after understanding test infrastructure - await Task.Delay(1, cts.Token); - }).ThrowsNothing(); - } -} -``` - -**Step 2: Run test to verify it passes (baseline)** - -Run: `cd TUnit.Engine.Tests && dotnet test --filter "FullyQualifiedName~ConstraintKeySchedulerConcurrencyTests"` -Expected: PASS (baseline test) - -**Step 3: Commit** - -```bash -git add TUnit.Engine.Tests/Scheduling/ConstraintKeySchedulerConcurrencyTests.cs -git commit -m "test: add concurrency stress test baseline for ConstraintKeyScheduler" -``` - ---- - -## Task 2: Replace LINQ with Manual Loops in ConstraintKeyScheduler (lines 56-69) - -**Files:** -- Modify: `TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs:56-69` - -**Step 1: Write test for constraint key checking behavior** - -```csharp -[Test] -public async Task ConstraintKeyCheck_WithNoConflicts_StartsImmediately() -{ - // This test verifies the behavior is unchanged after LINQ removal - // Exact implementation depends on testability of ConstraintKeyScheduler -} -``` - -**Step 2: Run existing tests to establish baseline** - -Run: `cd TUnit.Engine.Tests && dotnet test` -Expected: All tests PASS - -**Step 3: Replace LINQ `.Any()` with manual loop** - -In `ConstraintKeyScheduler.cs`, change lines 56-69 from: - -```csharp -lock (lockObject) -{ - // Check if all constraint keys are available - canStart = !constraintKeys.Any(key => lockedKeys.Contains(key)); - - if (canStart) - { - // Lock all the constraint keys for this test - foreach (var key in constraintKeys) - { - lockedKeys.Add(key); - } - } -} -``` - -To: - -```csharp -lock (lockObject) -{ - // Check if all constraint keys are available - manual loop avoids LINQ allocation - canStart = true; - var keyCount = constraintKeys.Count; - for (var i = 0; i < keyCount; i++) - { - if (lockedKeys.Contains(constraintKeys[i])) - { - canStart = false; - break; - } - } - - if (canStart) - { - // Lock all the constraint keys for this test - for (var i = 0; i < keyCount; i++) - { - lockedKeys.Add(constraintKeys[i]); - } - } -} -``` - -**Step 4: Run tests to verify behavior unchanged** - -Run: `cd TUnit.Engine.Tests && dotnet test` -Expected: All tests PASS - -**Step 5: Commit** - -```bash -git add TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs -git commit -m "perf: replace LINQ with manual loop in ConstraintKeyScheduler key checking" -``` - ---- - -## Task 3: Replace LINQ in Waiting Test Check (line 165) - -**Files:** -- Modify: `TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs:165` - -**Step 1: Run existing tests to establish baseline** - -Run: `cd TUnit.Engine.Tests && dotnet test` -Expected: All tests PASS - -**Step 2: Replace LINQ `.Any()` in waiting test check** - -In `ConstraintKeyScheduler.cs`, change line 165 from: - -```csharp -var canStart = !waitingTest.ConstraintKeys.Any(key => lockedKeys.Contains(key)); -``` - -To: - -```csharp -var canStart = true; -var waitingKeys = waitingTest.ConstraintKeys; -var waitingKeyCount = waitingKeys.Count; -for (var j = 0; j < waitingKeyCount; j++) -{ - if (lockedKeys.Contains(waitingKeys[j])) - { - canStart = false; - break; - } -} -``` - -**Step 3: Run tests to verify behavior unchanged** - -Run: `cd TUnit.Engine.Tests && dotnet test` -Expected: All tests PASS - -**Step 4: Commit** - -```bash -git add TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs -git commit -m "perf: replace LINQ with manual loop in waiting test availability check" -``` - ---- - -## Task 4: Move List Allocation Outside Lock (lines 149-190) - -**Files:** -- Modify: `TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs:149-190` - -**Step 1: Run existing tests to establish baseline** - -Run: `cd TUnit.Engine.Tests && dotnet test` -Expected: All tests PASS - -**Step 2: Pre-allocate lists outside lock scope** - -Change the structure from allocating `tempQueue` inside lock to pre-allocating outside. Replace lines 149-190: - -```csharp -// Release the constraint keys and check if any waiting tests can now run -var testsToStart = new List<(AbstractExecutableTest Test, IReadOnlyList ConstraintKeys, TaskCompletionSource StartSignal)>(4); -var testsToRequeue = new List<(AbstractExecutableTest Test, IReadOnlyList ConstraintKeys, TaskCompletionSource StartSignal)>(8); - -lock (lockObject) -{ - // Release all constraint keys for this test - var keyCount = constraintKeys.Count; - for (var i = 0; i < keyCount; i++) - { - lockedKeys.Remove(constraintKeys[i]); - } - - // Check waiting tests to see if any can now run - while (waitingTests.TryDequeue(out var waitingTest)) - { - // Check if all constraint keys are available for this waiting test - var canStart = true; - var waitingKeys = waitingTest.ConstraintKeys; - var waitingKeyCount = waitingKeys.Count; - for (var j = 0; j < waitingKeyCount; j++) - { - if (lockedKeys.Contains(waitingKeys[j])) - { - canStart = false; - break; - } - } - - if (canStart) - { - // Lock the keys for this test - for (var j = 0; j < waitingKeyCount; j++) - { - lockedKeys.Add(waitingKeys[j]); - } - - // Mark test to start after we exit the lock - testsToStart.Add(waitingTest); - } - else - { - // Still can't run, keep it for re-queuing - testsToRequeue.Add(waitingTest); - } - } - - // Re-add tests that still can't run - foreach (var waitingTestItem in testsToRequeue) - { - waitingTests.Enqueue(waitingTestItem); - } -} -``` - -**Step 3: Run tests to verify behavior unchanged** - -Run: `cd TUnit.Engine.Tests && dotnet test` -Expected: All tests PASS - -**Step 4: Commit** - -```bash -git add TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs -git commit -m "perf: pre-allocate lists outside lock scope in ConstraintKeyScheduler" -``` - ---- - -## Task 5: Add ImmutableList to ReflectionTestDataCollector - -**Files:** -- Modify: `TUnit.Engine/Discovery/ReflectionTestDataCollector.cs:31-32` - -**Step 1: Run existing tests to establish baseline** - -Run: `cd TUnit.Engine.Tests && dotnet test` -Expected: All tests PASS - -**Step 2: Add System.Collections.Immutable using and change field declaration** - -At the top of `ReflectionTestDataCollector.cs`, ensure this using is present: - -```csharp -using System.Collections.Immutable; -``` - -Change lines 31-32 from: - -```csharp -private static readonly List _discoveredTests = new(capacity: 1000); -private static readonly Lock _discoveredTestsLock = new(); -``` - -To: - -```csharp -private static ImmutableList _discoveredTests = ImmutableList.Empty; -``` - -**Step 3: Run tests (will fail - fields referenced elsewhere)** - -Run: `cd TUnit.Engine.Tests && dotnet test` -Expected: FAIL (compilation errors due to field changes) - -**Step 4: Commit partial change** - -```bash -git add TUnit.Engine/Discovery/ReflectionTestDataCollector.cs -git commit -m "refactor: change _discoveredTests to ImmutableList (WIP)" -``` - ---- - -## Task 6: Update CollectTestsAsync for ImmutableList - -**Files:** -- Modify: `TUnit.Engine/Discovery/ReflectionTestDataCollector.cs:136-141` - -**Step 1: Replace lock with atomic swap** - -Change lines 136-141 from: - -```csharp -// Add to discovered tests with lock -lock (_discoveredTestsLock) -{ - _discoveredTests.AddRange(newTests); - return new List(_discoveredTests); -} -``` - -To: - -```csharp -// Atomic swap - no lock needed for readers -ImmutableList original, updated; -do -{ - original = _discoveredTests; - updated = original.AddRange(newTests); -} while (Interlocked.CompareExchange(ref _discoveredTests, updated, original) != original); - -return _discoveredTests; -``` - -**Step 2: Run tests (may still fail if other usages remain)** - -Run: `cd TUnit.Engine.Tests && dotnet test` -Expected: May fail if streaming methods still use old lock - -**Step 3: Commit** - -```bash -git add TUnit.Engine/Discovery/ReflectionTestDataCollector.cs -git commit -m "perf: use atomic swap for CollectTestsAsync" -``` - ---- - -## Task 7: Update CollectTestsStreamingAsync for ImmutableList - -**Files:** -- Modify: `TUnit.Engine/Discovery/ReflectionTestDataCollector.cs:174-190` - -**Step 1: Replace lock with ImmutableInterlocked.Update** - -Change lines 174-178 from: - -```csharp -lock (_discoveredTestsLock) -{ - _discoveredTests.Add(test); -} -yield return test; -``` - -To: - -```csharp -ImmutableInterlocked.Update(ref _discoveredTests, list => list.Add(test)); -yield return test; -``` - -Similarly for lines 185-188: - -```csharp -ImmutableInterlocked.Update(ref _discoveredTests, list => list.Add(dynamicTest)); -yield return dynamicTest; -``` - -**Step 2: Run tests to verify streaming works** - -Run: `cd TUnit.Engine.Tests && dotnet test` -Expected: All tests PASS - -**Step 3: Commit** - -```bash -git add TUnit.Engine/Discovery/ReflectionTestDataCollector.cs -git commit -m "perf: use ImmutableInterlocked.Update for streaming discovery" -``` - ---- - -## Task 8: Update ClearCaches for ImmutableList - -**Files:** -- Modify: `TUnit.Engine/Discovery/ReflectionTestDataCollector.cs:47-60` - -**Step 1: Simplify ClearCaches to use atomic assignment** - -Change lines 50-53 from: - -```csharp -lock (_discoveredTestsLock) -{ - _discoveredTests.Clear(); -} -``` - -To: - -```csharp -Interlocked.Exchange(ref _discoveredTests, ImmutableList.Empty); -``` - -**Step 2: Run all tests** - -Run: `cd TUnit.Engine.Tests && dotnet test` -Expected: All tests PASS - -**Step 3: Commit** - -```bash -git add TUnit.Engine/Discovery/ReflectionTestDataCollector.cs -git commit -m "perf: simplify ClearCaches with atomic exchange" -``` - ---- - -## Task 9: Run Full Test Suite and Performance Verification - -**Files:** -- None (verification only) - -**Step 1: Run full TUnit test suite** - -Run: `dotnet test` -Expected: All tests PASS - -**Step 2: Run performance tests with dotnet-trace** - -```bash -cd TUnit.PerformanceTests -dotnet trace collect -- dotnet run -c Release -``` - -**Step 3: Verify no regressions** - -Compare trace output with baseline (if available). Look for: -- Reduced lock contention time -- Reduced thread wait time -- Similar or better wall clock time - -**Step 4: Final commit with summary** - -```bash -git add -A -git commit -m "perf: reduce lock contention in test discovery and scheduling - -Implements optimizations for #4162: -- ReflectionTestDataCollector: ImmutableList with atomic swap -- ConstraintKeyScheduler: manual loops replacing LINQ, pre-allocated lists - -This eliminates defensive copies under lock and reduces LINQ allocations -in hot paths during parallel test execution." -``` - ---- - -## Verification Checklist - -- [ ] All existing tests pass -- [ ] Stress tests pass under high contention -- [ ] No deadlocks under parallel execution -- [ ] Wall clock time equal or improved -- [ ] Lock contention reduced (verify with dotnet-trace) - ---- - -Plan complete and saved to `docs/plans/2025-12-26-lock-contention-optimization.md`. Two execution options: - -**1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration - -**2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints - -**Which approach?**