diff --git a/.github/workflows/dotnet-build-different-locale.yml b/.github/workflows/dotnet-build-different-locale.yml new file mode 100644 index 0000000000..2e7ef19b80 --- /dev/null +++ b/.github/workflows/dotnet-build-different-locale.yml @@ -0,0 +1,52 @@ +name: .NET + +on: + pull_request: + branches: ["main"] + +jobs: + modularpipeline: + strategy: + matrix: + locale: [fr-FR, pl-PL, de-DE] + fail-fast: true + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 8.0.x + + - name: Setup .NET 9 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 9.0.x + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Generate and set locale for subsequent steps + run: | + # Convert hyphen (fr-FR) to underscore (fr_FR) which is the correct locale name on Ubuntu + LOCALE=${{ matrix.locale }} + LOCALE=${LOCALE/-/_} + + sudo apt-get update + sudo apt-get install -y locales + sudo locale-gen "${LOCALE}.UTF-8" + sudo update-locale LANG="${LOCALE}.UTF-8" + + # Export for subsequent GitHub Actions steps + echo "LANG=${LOCALE}.UTF-8" >> $GITHUB_ENV + echo "LC_ALL=${LOCALE}.UTF-8" >> $GITHUB_ENV + + - name: Build + run: dotnet build -c Release + working-directory: TUnit.TestProject diff --git a/TUnit.Analyzers/TestDataAnalyzer.cs b/TUnit.Analyzers/TestDataAnalyzer.cs index bf53a25fcd..f7e7ad0965 100644 --- a/TUnit.Analyzers/TestDataAnalyzer.cs +++ b/TUnit.Analyzers/TestDataAnalyzer.cs @@ -952,12 +952,19 @@ private static bool CanConvert(SymbolAnalysisContext context, TypedConstant argu if (methodParameterType?.SpecialType == SpecialType.System_Decimal && argument.Type?.SpecialType == SpecialType.System_String && - argument.Value is string strValue && - decimal.TryParse(strValue, out _)) + argument.Value is string strValue) { - // 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; + // For string-to-decimal conversions in attributes, be permissive at compile time + // The runtime will handle culture-specific parsing with proper fallback + // Try both dot and comma as decimal separators since these are the most common + var normalizedValue = strValue.Replace(',', '.'); + if (decimal.TryParse(normalizedValue, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out _)) + { + // Allow string literals for decimal parameters + // e.g. [Arguments("123.456")] or [Arguments("123,456")] + return true; + } } return CanConvert(context, argument.Type, methodParameterType); diff --git a/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs b/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs index 9a3a20fd3b..173a21d1a2 100644 --- a/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs +++ b/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs @@ -201,7 +201,7 @@ private string FormatPrimitiveForCode(object? value, ITypeSymbol? targetType) } if (value is string s) { - return $"decimal.Parse(\"{s.ToInvariantString()}\", global::System.Globalization.CultureInfo.InvariantCulture)"; + return $"global::TUnit.Core.Helpers.DecimalParsingHelper.ParseDecimalWithCultureFallback(\"{s.ToInvariantString()}\")"; } if (value is double d) { diff --git a/TUnit.Core/Helpers/DecimalParsingHelper.cs b/TUnit.Core/Helpers/DecimalParsingHelper.cs new file mode 100644 index 0000000000..85273dbded --- /dev/null +++ b/TUnit.Core/Helpers/DecimalParsingHelper.cs @@ -0,0 +1,38 @@ +using System.Globalization; + +namespace TUnit.Core.Helpers; + +/// +/// Helper methods for parsing decimal values with culture fallback support +/// +public static class DecimalParsingHelper +{ + /// + /// Tries to parse a decimal value from a string, first using the current culture, + /// then falling back to the invariant culture if that fails. + /// This is useful for handling decimal values in attributes that might be written + /// with different decimal separators (e.g., "123.456" vs "123,456"). + /// + public static decimal ParseDecimalWithCultureFallback(string value) + { + // First, try parsing with the current culture + if (decimal.TryParse(value, NumberStyles.Any, CultureInfo.CurrentCulture, out var result)) + { + return result; + } + + // If that fails, try with the invariant culture + if (decimal.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out result)) + { + return result; + } + + // If both fail, throw an exception with helpful details + throw new FormatException( + $"Could not parse '{value}' as a decimal value. " + + $"Tried both CurrentCulture ({CultureInfo.CurrentCulture.Name}) " + + $"and InvariantCulture. " + + $"Valid decimal formats include: 123.456 (invariant) or locale-specific format." + ); + } +} \ No newline at end of file diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 7d3bd6aa05..d09afc696a 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1963,6 +1963,10 @@ namespace .Helpers "ute\' in target method or type.", Justification="We handle specific known tuple types without reflection")] public static object?[] UnwrapTupleAot(object? value) { } } + public static class DecimalParsingHelper + { + public static decimal ParseDecimalWithCultureFallback(string value) { } + } public static class GenericTypeHelper { public static GetGenericTypeDefinition( type) { } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 7499e3881a..c6b926d048 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1963,6 +1963,10 @@ namespace .Helpers "ute\' in target method or type.", Justification="We handle specific known tuple types without reflection")] public static object?[] UnwrapTupleAot(object? value) { } } + public static class DecimalParsingHelper + { + public static decimal ParseDecimalWithCultureFallback(string value) { } + } public static class GenericTypeHelper { public static GetGenericTypeDefinition( type) { } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index bd41555d43..ce32730367 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1863,6 +1863,10 @@ namespace .Helpers public static object?[] UnwrapTuple( tuple) { } public static object?[] UnwrapTupleAot(object? value) { } } + public static class DecimalParsingHelper + { + public static decimal ParseDecimalWithCultureFallback(string value) { } + } public static class GenericTypeHelper { public static GetGenericTypeDefinition( type) { } diff --git a/TUnit.TestProject/DecimalArgumentTests.cs b/TUnit.TestProject/DecimalArgumentTests.cs index 08b56864d2..3b65c90119 100644 --- a/TUnit.TestProject/DecimalArgumentTests.cs +++ b/TUnit.TestProject/DecimalArgumentTests.cs @@ -76,6 +76,15 @@ public async Task MultipleDecimals(decimal a, decimal b, decimal c) [Arguments(1)] public void Test(decimal test) { - return; + } + + + [Test] + [Arguments(50, 75, 70, 5, 0, true)] + [Arguments(70, 75, 70, 5, 5, true)] + [Arguments(70, 75, 70, 5, 0, false)] + public void TransactionDiscountCalculations(decimal amountPaying, decimal invoiceBalance, + decimal invoiceBalanceDue, decimal discountAmount, decimal appliedDiscountAmount, bool discountAllowedForUser) + { } }