Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ dotnet clean ReactiveUI.Binding.SourceGenerators.slnf
The working folder must be `./src` folder.

**IMPORTANT:**
- All TUnit test projects use `en-US` for the current culture and UI culture, configured by an assembly attribute in `Directory.Build.props`. Culture-specific regression tests use TUnit's `Culture` attribute to override it for that test.
- Do NOT use `--no-build` flag when running tests. Always build before testing to ensure all code changes are compiled.
- Use `--output Detailed` to see Console.WriteLine output from tests (place BEFORE any `--` separator).

Expand Down
4 changes: 4 additions & 0 deletions src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@

<!-- MTP Native JSON Configuration -->
<ItemGroup Condition="'$(IsTestProject)' == 'true'">
<!-- Keep ordinary tests independent of the host locale; culture regressions override this per test. -->
<AssemblyAttribute Include="TUnit.Core.Executors.CultureAttribute">
<_Parameter1>en-US</_Parameter1>
</AssemblyAttribute>
<None Include="$(MSBuildThisFileDirectory)testconfig.json">
<Link>$(AssemblyName).testconfig.json</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ namespace ReactiveUI.Binding;
#endif

/// <summary>
/// Converts a <see cref="float"/> to a <see cref="string"/> using the invariant culture, as generated bindings do. An
/// Converts a <see cref="float"/> to a <see cref="string"/> using the current culture. An
/// <see cref="int"/> hint gives the number of decimal places (the <c>F</c> format) and a <see cref="string"/> hint gives
/// the format string; both use the current culture, and a malformed format throws <see cref="FormatException"/>.
/// the format string; a malformed format throws <see cref="FormatException"/>.
/// </summary>
public sealed class SingleToStringTypeConverter : BindingTypeConverter<float, string>
{
Expand Down Expand Up @@ -40,7 +40,7 @@ public override bool TryConvert(float from, object? conversionHint, [NotNullWhen

default:
{
result = from.ToString(System.Globalization.CultureInfo.InvariantCulture);
result = from.ToString(System.Globalization.CultureInfo.CurrentCulture);
return true;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,15 @@ internal sealed class NumericStringConversionPlugin : IConversionPlugin
: null;
}

/// <summary>Preserves width, precision, custom format and the Single converter's invariant default.</summary>
/// <summary>Preserves width, precision and custom format using the current culture.</summary>
/// <param name="type">The unwrapped numeric type.</param>
/// <param name="nullable">Whether the input has a nullable wrapper.</param>
/// <returns>The concrete ToString operation.</returns>
internal static string FormattingExpression(ITypeSymbol type, bool nullable)
{
var value = nullable ? "__value.Value" : "__value";
var prefix = type.SpecialType is SpecialType.System_Single or SpecialType.System_Double or SpecialType.System_Decimal ? "F" : "D";
var culture = type.SpecialType == SpecialType.System_Single && !nullable ? "global::System.Globalization.CultureInfo.InvariantCulture" : string.Empty;
return $"__hint is int __precision ? {value}.ToString(\"{prefix}\" + __precision.ToString())"
+ $" : __hint is string __format ? {value}.ToString(__format) : {value}.ToString({culture})";
+ $" : __hint is string __format ? {value}.ToString(__format) : {value}.ToString()";
}
}
44 changes: 44 additions & 0 deletions src/examples/Documentation/Pages/converters/CultureExamples.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved.
// ReactiveUI and Contributors licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.

using System.Globalization;

namespace ReactiveUI.Binding.Documentation.Converters;

/// <summary>Demonstrates the culture used by numeric formatting.</summary>
public static class CultureExamples
{
/// <summary>Formats a float and its nullable counterpart with the current culture, including format hints.</summary>
public static void FormatSingleWithCurrentCulture()
{
CultureInfo? previousCulture = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR");
const float value = 3.14F;
SingleToStringTypeConverter converter = new SingleToStringTypeConverter();
NullableSingleToStringTypeConverter nullableConverter = new NullableSingleToStringTypeConverter();

_ = converter.TryConvert(value, conversionHint: null, out var plain);
_ = converter.TryConvert(value, conversionHint: 2, out var precision);
_ = converter.TryConvert(value, conversionHint: "F3", out var formatted);
_ = nullableConverter.TryConvert(value, conversionHint: null, out var nullable);

Console.WriteLine(plain);
Console.WriteLine(precision);
Console.WriteLine(formatted);
Console.WriteLine(nullable);

// Output:
// 3,14
// 3,14
// 3,140
// 3,14
}
finally
{
CultureInfo.CurrentCulture = previousCulture;
}
}
}
2 changes: 2 additions & 0 deletions src/examples/Documentation/Pages/converters/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@

NumberExamples.FormatStudentScore();

CultureExamples.FormatSingleWithCurrentCulture();

NumberExamples.ParseEulersNumber();

NumberExamples.FormatEulersNumber();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved.
// ReactiveUI and Contributors licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.

using System.Reflection;
using Microsoft.CodeAnalysis.CSharp;
using ReactiveUI.Binding.SourceGenerators.Tests.Helpers;

namespace ReactiveUI.Binding.SourceGenerators.Tests;

/// <summary>Verifies numeric formatting follows the consumer's culture in generated and runtime bindings.</summary>
public class NumericCultureConversionTests
{
/// <summary>Checks default, precision, format and unsupported hints for nullable and nonnullable values.</summary>
/// <param name="type">The numeric source type.</param>
/// <param name="name">The runtime converter name stem.</param>
/// <param name="value">The source value expression.</param>
/// <returns>A task representing the asynchronous test.</returns>
[Test]
[Arguments("float", "Single", "7.25f")]
public async Task NumericBindings_UseCurrentCulture(string type, string name, string value)
{
var result = TestHelper.RunGenerator(Scenario(type, name, value), LanguageVersion.CSharp10);
await result.CompilationSucceeds();
await result.GeneratedSourceDoesNotContain("BindToDispatch.g.cs", "InvariantCulture");
var (assembly, context) = TestHelper.EmitAndLoad(result);
try
{
var run = assembly.GetType("Usage")!.GetMethod("Run", BindingFlags.Public | BindingFlags.Static)!;
await Assert.That((bool)run.Invoke(null, null)!).IsTrue();
}
finally
{
context.Unload();
}
}

/// <summary>Builds formatting and round-trip probes with explicit culture-sensitive expectations.</summary>
/// <param name="type">The numeric source type.</param>
/// <param name="name">The runtime converter name stem.</param>
/// <param name="value">The source value expression.</param>
/// <returns>The executable consumer source.</returns>
private static string Scenario(string type, string name, string value) => $$"""
using System;
using System.Globalization;
using ReactiveUI.Binding;
public class Target { public string Value { get; set; } }
public class NumericTarget { public {{type}} Value { get; set; } }
public static class Usage
{
public static bool Run()
{
var previous = CultureInfo.CurrentCulture;
try
{
foreach (var culture in new[] { "en-US", "fr-FR" })
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture);
{{type}} value = {{value}};
foreach (object hint in new object[] { null, 3, "N2", new object() })
{
var expected = hint is int ? value.ToString("F" + (int)hint)
: hint is string ? value.ToString((string)hint) : value.ToString();
var target = new Target();
IObservable<{{type}}> values = new global::ReactiveUI.Primitives.Advanced.ImmediateReturnSignal<{{type}}>(value);
using (values.BindTo(target, x => x.Value, conversionHint: hint)) Check(expected, target.Value);
string formatted;
new {{name}}ToStringTypeConverter().TryConvert(value, hint, out formatted);
Check(expected, formatted);
IObservable<{{type}}?> nullableValues = new global::ReactiveUI.Primitives.Advanced.ImmediateReturnSignal<{{type}}?>(value);
using (nullableValues.BindTo(target, x => x.Value, conversionHint: hint)) Check(expected, target.Value);
new Nullable{{name}}ToStringTypeConverter().TryConvert(value, hint, out formatted);
Check(expected, formatted);
var roundTrip = new NumericTarget();
IObservable<string> text = new global::ReactiveUI.Primitives.Advanced.ImmediateReturnSignal<string>(target.Value);
using (text.BindTo(roundTrip, x => x.Value))
if (roundTrip.Value != value) throw new InvalidOperationException("Generated numeric round trip failed: " + target.Value);
{{type}} parsed;
if (!new StringTo{{name}}TypeConverter().TryConvert(formatted, null, out parsed) || parsed != value)
throw new InvalidOperationException("Runtime numeric round trip failed: " + formatted);
}
}
}
finally { CultureInfo.CurrentCulture = previous; }
return true;
}
private static void Check(string expected, string actual)
{
if (actual != expected) throw new InvalidOperationException(CultureInfo.CurrentCulture.Name + ": expected=" + expected + ", actual=" + actual);
}
}
""";
}
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ public Task ShortToString_Failure_Null() =>
[Test]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Task SingleToString_Success() =>
AssertConverterSuccess(new SingleToStringTypeConverter(), SingleValue, SingleValue.ToString(System.Globalization.CultureInfo.InvariantCulture));
AssertConverterSuccess(new SingleToStringTypeConverter(), SingleValue, SingleValue.ToString(System.Globalization.CultureInfo.CurrentCulture));

/// <summary>Verifies SingleToStringTypeConverter fails when given a wrong type.</summary>
/// <returns>A task representing the asynchronous test operation.</returns>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public async Task TryConvert_DecimalToString_Succeeds()
var result = converter.TryConvert(value, null, out var output);

await Assert.That(result).IsTrue();
await Assert.That(output).IsEqualTo("123.456");
await Assert.That(output).IsEqualTo(value.ToString(System.Globalization.CultureInfo.CurrentCulture));
}

/// <summary>Verifies TryConvert MaxValue Succeeds.</summary>
Expand Down Expand Up @@ -73,7 +73,7 @@ public async Task TryConvert_NegativeValue_Succeeds()
var result = converter.TryConvert(value, null, out var output);

await Assert.That(result).IsTrue();
await Assert.That(output).IsEqualTo("-123.456");
await Assert.That(output).IsEqualTo(value.ToString(System.Globalization.CultureInfo.CurrentCulture));
}

/// <summary>Verifies TryConvert WithConversionHint FormatsCorrectly.</summary>
Expand All @@ -87,7 +87,7 @@ public async Task TryConvert_WithConversionHint_FormatsCorrectly()
var result = converter.TryConvert(value, ExpectedAffinity, out var output);

await Assert.That(result).IsTrue();
await Assert.That(output).IsEqualTo("42.50");
await Assert.That(output).IsEqualTo(value.ToString("F2", System.Globalization.CultureInfo.CurrentCulture));
}

/// <summary>Verifies TryConvert WithStringFormatHint CurrencyFormat.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ public async Task TryConvert_WithConversionHint_FormatsCorrectly()
var result = converter.TryConvert(value, ExpectedAffinity, out var output);

await Assert.That(result).IsTrue();
await Assert.That(output).IsEqualTo("42.50");
await Assert.That(output).IsEqualTo(value.ToString("F2", System.Globalization.CultureInfo.CurrentCulture));
}

/// <summary>Verifies TryConvert WithStringFormatHint CustomPrecision.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public async Task TryConvert_DecimalNullableToString_Succeeds()
var result = converter.TryConvert(SampleDecimal, null, out var output);

await Assert.That(result).IsTrue();
await Assert.That(output).IsEqualTo("123.456");
await Assert.That(output).IsEqualTo(SampleDecimal.ToString(System.Globalization.CultureInfo.CurrentCulture));
}

/// <summary>Verifies TryConvert MaxValue Succeeds.</summary>
Expand Down Expand Up @@ -78,7 +78,7 @@ public async Task TryConvert_NegativeValue_Succeeds()
var result = converter.TryConvert(NegativeDecimal, null, out var output);

await Assert.That(result).IsTrue();
await Assert.That(output).IsEqualTo("-123.456");
await Assert.That(output).IsEqualTo(NegativeDecimal.ToString(System.Globalization.CultureInfo.CurrentCulture));
}

/// <summary>Verifies TryConvert NullValue ReturnsTrue.</summary>
Expand All @@ -101,7 +101,7 @@ public async Task TryConvert_WithConversionHint_FormatsCorrectly()
var result = converter.TryConvert(FormattedDecimal, ExpectedAffinity, out var output);

await Assert.That(result).IsTrue();
await Assert.That(output).IsEqualTo("42.50");
await Assert.That(output).IsEqualTo(FormattedDecimal.ToString("F2", System.Globalization.CultureInfo.CurrentCulture));
}

/// <summary>Verifies TryConvert Zero Succeeds.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ public async Task TryConvert_WithConversionHint_FormatsCorrectly()
var result = converter.TryConvert(FormattedDouble, ExpectedAffinity, out var output);

await Assert.That(result).IsTrue();
await Assert.That(output).IsEqualTo("42.50");
await Assert.That(output).IsEqualTo(FormattedDouble.ToString("F2", System.Globalization.CultureInfo.CurrentCulture));
}

/// <summary>Verifies TryConvert with a string format hint formats correctly.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ public async Task TryConvert_WithConversionHint_FormatsCorrectly()
var result = converter.TryConvert(FormattedSingle, ExpectedAffinity, out var output);

await Assert.That(result).IsTrue();
await Assert.That(output).IsEqualTo("42.50");
await Assert.That(output).IsEqualTo(FormattedSingle.ToString("F2", System.Globalization.CultureInfo.CurrentCulture));
}

/// <summary>Verifies TryConvert with a string format hint formats correctly.</summary>
Expand Down
Loading
Loading