diff --git a/TUnit.Analyzers/TestDataAnalyzer.cs b/TUnit.Analyzers/TestDataAnalyzer.cs index 07d0b731b3..098eb2267e 100644 --- a/TUnit.Analyzers/TestDataAnalyzer.cs +++ b/TUnit.Analyzers/TestDataAnalyzer.cs @@ -934,6 +934,16 @@ private static bool CanConvert(SymbolAnalysisContext context, TypedConstant argu return true; } + if (methodParameterType?.SpecialType == SpecialType.System_Decimal && + argument.Type?.SpecialType == SpecialType.System_String && + argument.Value is string strValue && + decimal.TryParse(strValue, out _)) + { + // Allow string literals for decimal parameters for values that can't be expressed as C# numeric literals + // e.g. [Arguments("79228162514264337593543950335")] for decimal.MaxValue + return true; + } + return CanConvert(context, argument.Type, methodParameterType); } diff --git a/TUnit.Core.SourceGenerator.Tests/ConstantArgumentsTests.Test.verified.txt b/TUnit.Core.SourceGenerator.Tests/ConstantArgumentsTests.Test.verified.txt index 8c0250dc18..4a46e6e6ff 100644 --- a/TUnit.Core.SourceGenerator.Tests/ConstantArgumentsTests.Test.verified.txt +++ b/TUnit.Core.SourceGenerator.Tests/ConstantArgumentsTests.Test.verified.txt @@ -251,7 +251,7 @@ internal sealed class ConstantArgumentsTests_Double_TestSource_GUID : global::TU ], DataSources = new global::TUnit.Core.IDataSourceAttribute[] { - new global::TUnit.Core.ArgumentsAttribute(1.23d), + new global::TUnit.Core.ArgumentsAttribute(1.23), }, ClassDataSources = global::System.Array.Empty(), PropertyDataSources = global::System.Array.Empty(), @@ -593,7 +593,7 @@ internal sealed class ConstantArgumentsTests_UInt_TestSource_GUID : global::TUni ], DataSources = new global::TUnit.Core.IDataSourceAttribute[] { - new global::TUnit.Core.ArgumentsAttribute(123U), + new global::TUnit.Core.ArgumentsAttribute(123u), }, ClassDataSources = global::System.Array.Empty(), PropertyDataSources = global::System.Array.Empty(), diff --git a/TUnit.Core.SourceGenerator.Tests/DataDrivenTests.Test.verified.txt b/TUnit.Core.SourceGenerator.Tests/DataDrivenTests.Test.verified.txt index fc73911e33..3c3bffb73c 100644 --- a/TUnit.Core.SourceGenerator.Tests/DataDrivenTests.Test.verified.txt +++ b/TUnit.Core.SourceGenerator.Tests/DataDrivenTests.Test.verified.txt @@ -274,7 +274,7 @@ internal sealed class DataDrivenTests_EnumValue_TestSource_GUID : global::TUnit. { new global::TUnit.Core.ArgumentsAttribute(global::TUnit.TestProject.TestEnum.One), new global::TUnit.Core.ArgumentsAttribute(global::TUnit.TestProject.TestEnum.Two), - new global::TUnit.Core.ArgumentsAttribute(-1), + new global::TUnit.Core.ArgumentsAttribute((global::TUnit.TestProject.TestEnum)(-1)), }, ClassDataSources = global::System.Array.Empty(), PropertyDataSources = global::System.Array.Empty(), diff --git a/TUnit.Core.SourceGenerator.Tests/NullableByteArgumentTests.Test.verified.txt b/TUnit.Core.SourceGenerator.Tests/NullableByteArgumentTests.Test.verified.txt index 741e713e99..d7b91449cb 100644 --- a/TUnit.Core.SourceGenerator.Tests/NullableByteArgumentTests.Test.verified.txt +++ b/TUnit.Core.SourceGenerator.Tests/NullableByteArgumentTests.Test.verified.txt @@ -142,8 +142,8 @@ internal sealed class NullableByteArgumentTests_Test2_TestSource_GUID : global:: ], DataSources = new global::TUnit.Core.IDataSourceAttribute[] { - new global::TUnit.Core.ArgumentsAttribute(1, 1), - new global::TUnit.Core.ArgumentsAttribute(1, null), + new global::TUnit.Core.ArgumentsAttribute((byte)1, 1), + new global::TUnit.Core.ArgumentsAttribute((byte)1, null), }, ClassDataSources = global::System.Array.Empty(), PropertyDataSources = global::System.Array.Empty(), diff --git a/TUnit.Core.SourceGenerator.Tests/NumberArgumentTests.Test.verified.txt b/TUnit.Core.SourceGenerator.Tests/NumberArgumentTests.Test.verified.txt index dd24b25f71..a5d68e9eb4 100644 --- a/TUnit.Core.SourceGenerator.Tests/NumberArgumentTests.Test.verified.txt +++ b/TUnit.Core.SourceGenerator.Tests/NumberArgumentTests.Test.verified.txt @@ -139,7 +139,7 @@ internal sealed class NumberArgumentTests_Double_TestSource_GUID : global::TUnit ], DataSources = new global::TUnit.Core.IDataSourceAttribute[] { - new global::TUnit.Core.ArgumentsAttribute(1.1d), + new global::TUnit.Core.ArgumentsAttribute(1.1), }, ClassDataSources = global::System.Array.Empty(), PropertyDataSources = global::System.Array.Empty(), @@ -603,7 +603,7 @@ internal sealed class NumberArgumentTests_UInt_TestSource_GUID : global::TUnit.C ], DataSources = new global::TUnit.Core.IDataSourceAttribute[] { - new global::TUnit.Core.ArgumentsAttribute(1U), + new global::TUnit.Core.ArgumentsAttribute(1u), }, ClassDataSources = global::System.Array.Empty(), PropertyDataSources = global::System.Array.Empty(), diff --git a/TUnit.Core.SourceGenerator.Tests/NumberArgumentTests.TestDE.verified.txt b/TUnit.Core.SourceGenerator.Tests/NumberArgumentTests.TestDE.verified.txt index dd24b25f71..7a4a7c009c 100644 --- a/TUnit.Core.SourceGenerator.Tests/NumberArgumentTests.TestDE.verified.txt +++ b/TUnit.Core.SourceGenerator.Tests/NumberArgumentTests.TestDE.verified.txt @@ -139,7 +139,7 @@ internal sealed class NumberArgumentTests_Double_TestSource_GUID : global::TUnit ], DataSources = new global::TUnit.Core.IDataSourceAttribute[] { - new global::TUnit.Core.ArgumentsAttribute(1.1d), + new global::TUnit.Core.ArgumentsAttribute(1,1), }, ClassDataSources = global::System.Array.Empty(), PropertyDataSources = global::System.Array.Empty(), @@ -255,7 +255,7 @@ internal sealed class NumberArgumentTests_Float_TestSource_GUID : global::TUnit. ], DataSources = new global::TUnit.Core.IDataSourceAttribute[] { - new global::TUnit.Core.ArgumentsAttribute(1.1f), + new global::TUnit.Core.ArgumentsAttribute(1,1f), }, ClassDataSources = global::System.Array.Empty(), PropertyDataSources = global::System.Array.Empty(), @@ -603,7 +603,7 @@ internal sealed class NumberArgumentTests_UInt_TestSource_GUID : global::TUnit.C ], DataSources = new global::TUnit.Core.IDataSourceAttribute[] { - new global::TUnit.Core.ArgumentsAttribute(1U), + new global::TUnit.Core.ArgumentsAttribute(1u), }, ClassDataSources = global::System.Array.Empty(), PropertyDataSources = global::System.Array.Empty(), diff --git a/TUnit.Core.SourceGenerator.Tests/Tests1603.Test.verified.txt b/TUnit.Core.SourceGenerator.Tests/Tests1603.Test.verified.txt index 04a312bb72..4db6efa90e 100644 --- a/TUnit.Core.SourceGenerator.Tests/Tests1603.Test.verified.txt +++ b/TUnit.Core.SourceGenerator.Tests/Tests1603.Test.verified.txt @@ -23,7 +23,7 @@ internal sealed class Tests_Casted_Integer_To_Short_Converts_TestSource_GUID : g ], DataSources = new global::TUnit.Core.IDataSourceAttribute[] { - new global::TUnit.Core.ArgumentsAttribute(-123), + new global::TUnit.Core.ArgumentsAttribute((short)-123), }, ClassDataSources = global::System.Array.Empty(), PropertyDataSources = global::System.Array.Empty(), @@ -137,7 +137,7 @@ internal sealed class Tests_Integer_To_Short_Converts_TestSource_GUID : global:: ], DataSources = new global::TUnit.Core.IDataSourceAttribute[] { - new global::TUnit.Core.ArgumentsAttribute(-123), + new global::TUnit.Core.ArgumentsAttribute((short)-123), }, ClassDataSources = global::System.Array.Empty(), PropertyDataSources = global::System.Array.Empty(), diff --git a/TUnit.Core.SourceGenerator.Tests/Tests2083.Test.verified.txt b/TUnit.Core.SourceGenerator.Tests/Tests2083.Test.verified.txt index 712dbe8b41..dfdf0fe102 100644 --- a/TUnit.Core.SourceGenerator.Tests/Tests2083.Test.verified.txt +++ b/TUnit.Core.SourceGenerator.Tests/Tests2083.Test.verified.txt @@ -28,11 +28,11 @@ internal sealed class Tests_MyTest_TestSource_GUID : global::TUnit.Core.Interfac ], DataSources = new global::TUnit.Core.IDataSourceAttribute[] { - new global::TUnit.Core.ArgumentsAttribute(0), - new global::TUnit.Core.ArgumentsAttribute(255), - new global::TUnit.Core.ArgumentsAttribute(32767), - new global::TUnit.Core.ArgumentsAttribute('\uffff'), - new global::TUnit.Core.ArgumentsAttribute(2147483647), + new global::TUnit.Core.ArgumentsAttribute(0L), + new global::TUnit.Core.ArgumentsAttribute(255L), + new global::TUnit.Core.ArgumentsAttribute(32767L), + new global::TUnit.Core.ArgumentsAttribute(65535L), + new global::TUnit.Core.ArgumentsAttribute(2147483647L), new global::TUnit.Core.ArgumentsAttribute(9223372036854775807L), }, ClassDataSources = global::System.Array.Empty(), diff --git a/TUnit.Core.SourceGenerator/CodeGenerationHelpers.cs b/TUnit.Core.SourceGenerator/CodeGenerationHelpers.cs index 7d884cf654..c01ebda2bd 100644 --- a/TUnit.Core.SourceGenerator/CodeGenerationHelpers.cs +++ b/TUnit.Core.SourceGenerator/CodeGenerationHelpers.cs @@ -1,4 +1,5 @@ using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; using TUnit.Core.SourceGenerator.CodeGenerators.Helpers; using TUnit.Core.SourceGenerator.CodeGenerators.Writers; using TUnit.Core.SourceGenerator.Extensions; @@ -54,17 +55,32 @@ public static string GenerateParameterMetadataArray(IMethodSymbol method) /// /// Generates direct instantiation code for attributes. /// - public static string GenerateAttributeInstantiation(AttributeData attr) + public static string GenerateAttributeInstantiation(AttributeData attr, IMethodSymbol? targetMethod = null) { var typeName = attr.AttributeClass!.GloballyQualified(); using var writer = new CodeWriter("", includeHeader: false); writer.SetIndentLevel(1); writer.Append($"new {typeName}("); + // Try to get the original syntax for better precision with decimal literals + var syntax = attr.ApplicationSyntaxReference?.GetSyntax(); + var syntaxArguments = syntax?.ChildNodes() + .OfType() + .FirstOrDefault() + ?.Arguments.Where(x => x.NameEquals == null).ToList(); + if (attr.ConstructorArguments.Length > 0) { var argStrings = new List(); + // Determine if this is an Arguments attribute and get parameter types + ITypeSymbol[]? parameterTypes = null; + if (attr.AttributeClass?.Name == "ArgumentsAttribute" && targetMethod != null) + { + parameterTypes = targetMethod.Parameters.Select(p => p.Type).ToArray(); + } + + var syntaxIndex = 0; for (var i = 0; i < attr.ConstructorArguments.Length; i++) { var arg = attr.ConstructorArguments[i]; @@ -76,18 +92,105 @@ public static string GenerateAttributeInstantiation(AttributeData attr) { if (!arg.Values.IsDefault) { - var elements = arg.Values.Select(TypedConstantParser.GetRawTypedConstantValue); + var elementIndex = 0; + var elements = arg.Values.Select(v => + { + var paramType = parameterTypes != null && elementIndex < parameterTypes.Length + ? parameterTypes[elementIndex] + : null; + + // For decimal parameters with syntax available, use the original text + if (paramType?.SpecialType == SpecialType.System_Decimal && + syntaxArguments != null && syntaxIndex < syntaxArguments.Count) + { + var originalText = syntaxArguments[syntaxIndex].Expression.ToString(); + syntaxIndex++; + // Check if it's a string literal (starts and ends with quotes) + if (originalText.StartsWith("\"") && originalText.EndsWith("\"")) + { + // For string literals, let the normal processing handle it (will use decimal.Parse) + syntaxIndex--; // Back up so normal processing can handle it + elementIndex++; + return TypedConstantParser.GetRawTypedConstantValue(v, paramType); + } + else + { + // For numeric literals, remove any suffix and add 'm' for decimal + originalText = originalText.TrimEnd('d', 'D', 'f', 'F', 'l', 'L', 'u', 'U', 'm', 'M'); + return $"{originalText}m"; + } + } + + syntaxIndex++; + elementIndex++; + return TypedConstantParser.GetRawTypedConstantValue(v, paramType); + }).ToList(); argStrings.AddRange(elements); } } else { - argStrings.Add(TypedConstantParser.GetRawTypedConstantValue(arg)); + var paramType = parameterTypes != null && i < parameterTypes.Length ? parameterTypes[i] : null; + + // For decimal parameters with syntax available, use the original text + if (paramType?.SpecialType == SpecialType.System_Decimal && + syntaxArguments != null && syntaxIndex < syntaxArguments.Count) + { + var originalText = syntaxArguments[syntaxIndex].Expression.ToString(); + syntaxIndex++; + // Check if it's a string literal (starts and ends with quotes) + if (originalText.StartsWith("\"") && originalText.EndsWith("\"")) + { + // For string literals, let the normal processing handle it (will use decimal.Parse) + syntaxIndex--; // Back up so normal processing can handle it + argStrings.Add(TypedConstantParser.GetRawTypedConstantValue(arg, paramType)); + } + else + { + // For numeric literals, remove any suffix and add 'm' for decimal + originalText = originalText.TrimEnd('d', 'D', 'f', 'F', 'l', 'L', 'u', 'U', 'm', 'M'); + argStrings.Add($"{originalText}m"); + } + } + else + { + syntaxIndex++; + argStrings.Add(TypedConstantParser.GetRawTypedConstantValue(arg, paramType)); + } } } else { - argStrings.Add(TypedConstantParser.GetRawTypedConstantValue(arg)); + var paramType = parameterTypes != null && i < parameterTypes.Length ? parameterTypes[i] : null; + + // For decimal parameters with syntax available, use the original text + if (paramType?.SpecialType == SpecialType.System_Decimal && + syntaxArguments != null && syntaxIndex < syntaxArguments.Count) + { + var originalText = syntaxArguments[syntaxIndex].Expression.ToString(); + syntaxIndex++; + // Check if it's a string literal (starts and ends with quotes) + if (originalText.StartsWith("\"") && originalText.EndsWith("\"")) + { + // For string literals, let the normal processing handle it (will use decimal.Parse) + syntaxIndex--; // Back up so normal processing can handle it + argStrings.Add(TypedConstantParser.GetRawTypedConstantValue(arg, paramType)); + } + else + { + // For numeric literals, remove any suffix and add 'm' for decimal + originalText = originalText.TrimEnd('d', 'D', 'f', 'F', 'l', 'L', 'u', 'U', 'm', 'M'); + argStrings.Add($"{originalText}m"); + } + } + else + { + if (syntaxArguments != null && syntaxIndex < syntaxArguments.Count) + { + syntaxIndex++; + } + argStrings.Add(TypedConstantParser.GetRawTypedConstantValue(arg, paramType)); + } } } @@ -311,7 +414,7 @@ private static string GenerateInlineDataProvider(AttributeData attr) using var writer = new CodeWriter("", includeHeader: false); writer.Append("new global::TUnit.Core.StaticTestDataSource(new object?[][] { new object?[] { "); - var args = attr.ConstructorArguments.Select(TypedConstantParser.GetRawTypedConstantValue).ToList(); + var args = attr.ConstructorArguments.Select(arg => TypedConstantParser.GetRawTypedConstantValue(arg)).ToList(); writer.Append(string.Join(", ", args)); writer.Append(" } })"); return writer.ToString().Trim(); diff --git a/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs b/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs index f4e0e38d86..d358d7d13d 100644 --- a/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs +++ b/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs @@ -167,6 +167,33 @@ private string FormatPrimitiveForCode(object? value, ITypeSymbol? targetType) // Double is default for floating-point literals return value.ToString()!; case SpecialType.System_Decimal: + // Handle string to decimal conversion for values that can't be expressed as literals + if (value is string s) + { + // Generate code that parses the string at runtime + // This allows for maximum precision decimal values + return $"decimal.Parse(\"{s}\", System.Globalization.CultureInfo.InvariantCulture)"; + } + // When target is decimal but value is double/float/int, convert and format with m suffix + else if (value is double d) + { + // Use the full precision by formatting with sufficient digits + // The 'G29' format gives us the maximum precision for decimal + var decimalValue = (decimal)d; + return $"{decimalValue.ToString("G29", System.Globalization.CultureInfo.InvariantCulture)}m"; + } + else if (value is float f) + { + var decimalValue = (decimal)f; + return $"{decimalValue.ToString("G29", System.Globalization.CultureInfo.InvariantCulture)}m"; + } + else if (value is int || value is long || value is short || value is byte || + value is uint || value is ulong || value is ushort || value is sbyte) + { + // For integer types, convert to decimal + var decimalValue = Convert.ToDecimal(value); + return $"{decimalValue.ToString(System.Globalization.CultureInfo.InvariantCulture)}m"; + } return $"{value}m"; } } diff --git a/TUnit.Core.SourceGenerator/CodeGenerators/Helpers/TypedConstantParser.cs b/TUnit.Core.SourceGenerator/CodeGenerators/Helpers/TypedConstantParser.cs index c754210b38..1db5ea85ce 100644 --- a/TUnit.Core.SourceGenerator/CodeGenerators/Helpers/TypedConstantParser.cs +++ b/TUnit.Core.SourceGenerator/CodeGenerators/Helpers/TypedConstantParser.cs @@ -66,10 +66,10 @@ public static string GetFullyQualifiedTypeNameFromTypedConstantValue(TypedConsta return typedConstant.Type!.GloballyQualified(); } - public static string GetRawTypedConstantValue(TypedConstant typedConstant) + public static string GetRawTypedConstantValue(TypedConstant typedConstant, ITypeSymbol? targetType = null) { // Use the formatter for consistent handling - return _formatter.FormatForCode(typedConstant); + return _formatter.FormatForCode(typedConstant, targetType); } private static string FormatPrimitive(TypedConstant typedConstant) diff --git a/TUnit.Core.SourceGenerator/CodeGenerators/Writers/AttributeWriter.cs b/TUnit.Core.SourceGenerator/CodeGenerators/Writers/AttributeWriter.cs index b598bf442f..44dcf0c647 100644 --- a/TUnit.Core.SourceGenerator/CodeGenerators/Writers/AttributeWriter.cs +++ b/TUnit.Core.SourceGenerator/CodeGenerators/Writers/AttributeWriter.cs @@ -196,7 +196,7 @@ public static void WriteAttributeWithoutSyntax(ICodeWriter sourceCodeWriter, Att return; } - var constructorArgs = attributeData.ConstructorArguments.Select(TypedConstantParser.GetRawTypedConstantValue); + var constructorArgs = attributeData.ConstructorArguments.Select(arg => TypedConstantParser.GetRawTypedConstantValue(arg)); var formattedConstructorArgs = string.Join(", ", constructorArgs); var namedArgs = attributeData.NamedArguments.Select(arg => $"{arg.Key} = {TypedConstantParser.GetRawTypedConstantValue(arg.Value)}"); diff --git a/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index 62379a60ac..6c3a6f96c1 100644 --- a/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -702,7 +702,8 @@ private static void GenerateDataSourceAttribute(CodeWriter writer, AttributeData { // Use the generic attribute instantiation method for all other attributes // This properly handles generics on the attribute type - var generatedCode = CodeGenerationHelpers.GenerateAttributeInstantiation(attr); + // Pass methodSymbol for ArgumentsAttribute to preserve decimal precision + var generatedCode = CodeGenerationHelpers.GenerateAttributeInstantiation(attr, methodSymbol); writer.AppendLine($"{generatedCode},"); } } diff --git a/TUnit.TestProject/DecimalArgumentTests.cs b/TUnit.TestProject/DecimalArgumentTests.cs new file mode 100644 index 0000000000..11b6716d73 --- /dev/null +++ b/TUnit.TestProject/DecimalArgumentTests.cs @@ -0,0 +1,72 @@ +using System.Globalization; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject; + +[EngineTest(ExpectedResult.Pass)] +public class DecimalArgumentTests +{ + [Test] + [Arguments(2_000, 123_999.00000000000000001)] + [Arguments(2_000.00000000000000001, 123_999)] + [Arguments(2_000.00000000000000001, 123_999.00000000000000001)] + public async Task Transfer(decimal debit, decimal credit) + { + // Test that the decimal values maintain their precision + // Check if the value is one of the expected values (with or without extended precision) + await Assert.That(debit is 2_000m or 2_000.00000000000000001m).IsTrue(); + await Assert.That(credit is 123_999m or 123_999.00000000000000001m).IsTrue(); + + // The precision test - these should preserve the original literal values + if (debit == 2_000.00000000000000001m && credit == 123_999.00000000000000001m) + { + // This test case has both values with extended precision + await Assert.That(debit.ToString(CultureInfo.InvariantCulture)).IsEqualTo("2000.00000000000000001"); + await Assert.That(credit.ToString(CultureInfo.InvariantCulture)).IsEqualTo("123999.00000000000000001"); + } + } + + [Test] + [Arguments(123.456)] + public async Task SimpleDecimal(decimal value) + { + await Assert.That(value).IsEqualTo(123.456m); + } + + [Test] + [Arguments(0.00000000000000001)] + public async Task SmallDecimal(decimal value) + { + await Assert.That(value).IsEqualTo(0.00000000000000001m); + } + + [Test] + [Arguments("79228162514264337593543950335")] // Max decimal value as string + public async Task MaxDecimal(decimal value) + { + await Assert.That(value).IsEqualTo(decimal.MaxValue); + } + + [Test] + [Arguments("-79228162514264337593543950335")] // Min decimal value as string + public async Task MinDecimal(decimal value) + { + await Assert.That(value).IsEqualTo(decimal.MinValue); + } + + [Test] + [Arguments("123.456")] // Decimal value as string + public async Task ExplicitDecimalValue(decimal value) + { + await Assert.That(value).IsEqualTo(123.456m); + } + + [Test] + [Arguments(1.1, 2.2, 3.3)] // Multiple decimal arguments + public async Task MultipleDecimals(decimal a, decimal b, decimal c) + { + await Assert.That(a).IsEqualTo(1.1m); + await Assert.That(b).IsEqualTo(2.2m); + await Assert.That(c).IsEqualTo(3.3m); + } +}