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