From 94ebc58668ed4b0fc28d77821270e296c4d89c9f Mon Sep 17 00:00:00 2001 From: Tony-ST0754 <6914529@qq.com> Date: Sat, 5 Sep 2026 15:45:19 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(InputNumber):=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E6=95=B0=E5=AD=97=E6=A0=BC=E5=BC=8F=E5=8C=96=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor:支持数字格式化显示,例如:$123,252.12 feat:增加文化信息参数 feat:增加Parser,以便与Formatter成配套使用;即Formatter可以输出任意格式,Parser负责反向 --- .../Components/Samples/InputNumbers.razor | 51 ++++++------ .../Components/Samples/InputNumbers.razor.cs | 27 ++++++- src/BootstrapBlazor.Server/appsettings.json | 6 +- .../InputNumber/BootstrapInputNumber.razor | 4 +- .../InputNumber/BootstrapInputNumber.razor.cs | 31 ++++++- .../Extensions/ObjectExtensions.cs | 57 +++++++++++++ test/UnitTest/Components/InputNumberTest.cs | 80 +++++++++++++++++++ .../Extensions/ObjectExtensionsTest.cs | 29 +++++++ 8 files changed, 251 insertions(+), 34 deletions(-) diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor b/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor index d7e0a5be2e1..d19b8a6dd82 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor +++ b/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor @@ -5,7 +5,7 @@

@Localizer["InputNumbersDescription"]

-
@@ -19,25 +19,27 @@
- +*@
- +
- +
- +
-
+ @*
+
@@ -48,11 +50,11 @@
-
+
*@
-

@Localizer["InputNumbersShowButtonDescription"]

@@ -67,9 +69,9 @@
-
+ *@ -
@@ -111,12 +113,12 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se

@Localizer["InputNumbersStep0.1"]

-
+ -

@Localizer["InputNumbersColorDescription1"]

+

@Localizer["InputNumbersColorDescription1"]

@@ -161,7 +163,7 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se

@Localizer["InputNumbersColorDescription2"]

-
+
@@ -190,10 +192,10 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se
-
+
- +
@@ -202,13 +204,13 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se
- +*@
-
+ @*
@@ -225,23 +227,25 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se
- +
-
+
*@
- +
- +
-
@@ -252,7 +256,7 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se
-
+*@ + diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs b/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs index a1f831f0029..323a033a871 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs +++ b/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs @@ -1,8 +1,11 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the Apache 2.0 License // See the LICENSE file in the project root for more information. // Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone +using System.Globalization; +using System.Text.RegularExpressions; + namespace BootstrapBlazor.Server.Components.Samples; /// @@ -51,9 +54,27 @@ public sealed partial class InputNumbers private double? InputNullableValue { get; set; } = 12.01; - private double InputValue { get; set; } = 12.01; + private decimal InputValue { get; set; } = 12.01m; - private int? BindInputNullableValue { get; set; } = 2; + private double BindInputNullableValue { get; set; } = 2; private int BindInputValue { get; set; } = 2; + + private string Format1(double value) + { + double.TryParse("$235,623.235", NumberStyles.Any, CultureInfo.GetCultureInfo("en-US"), out var number); + double.TryParse("¥1235,623.235", NumberStyles.Currency, CultureInfo.GetCultureInfo("zh-CN"), out var number1); + return $"$ {value.ToString("n0")}"; + } + + private string Format2(decimal value) + { + return $"{value.ToString("n2")}%"; + } + + private (bool Success, decimal Value) Parser1(string arg1, CultureInfo info) + { + var ret = (true, decimal.Parse(arg1.Replace("%", ""), info)); + return ret; + } } diff --git a/src/BootstrapBlazor.Server/appsettings.json b/src/BootstrapBlazor.Server/appsettings.json index 695457f9d91..7f18fdb96c5 100644 --- a/src/BootstrapBlazor.Server/appsettings.json +++ b/src/BootstrapBlazor.Server/appsettings.json @@ -50,9 +50,9 @@ "Short": "1", "Int": "1", "Long": "1", - "Float": "0.1", - "Double": "0.01", - "Decimal": "0.01" + "Float": "any", + "Double": "any", + "Decimal": "any" }, "ConnectionHubOptions": { "Enable": true, diff --git a/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor b/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor index 61e479c3ea5..b6960d717b5 100644 --- a/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor +++ b/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor @@ -1,4 +1,4 @@ -@namespace BootstrapBlazor.Components +@namespace BootstrapBlazor.Components @typeparam TValue @inherits BootstrapInputNumberBase @@ -25,5 +25,5 @@ else @code { RenderFragment RenderInput => - @; + @; } diff --git a/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor.cs b/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor.cs index 0b73672aaec..c221f3f01f9 100644 --- a/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor.cs +++ b/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor.cs @@ -93,6 +93,20 @@ public partial class BootstrapInputNumber [Parameter] public string? PlusIcon { get; set; } + /// + /// 获得/设置 数值解析使用的文化信息 + /// Gets or sets the culture used to parse numeric values + /// + [Parameter] + public CultureInfo CultureInfo { get; set; } = CultureInfo.CurrentCulture; + + /// + /// 获得/设置 自定义数值解析回调方法 + /// Gets or sets the callback used to parse custom numeric values + /// + [Parameter] + public Func? Parser { get; set; } + [Inject] [NotNull] private IStringLocalizer>? Localizer { get; set; } @@ -201,9 +215,9 @@ private string GetStepString() return StepString; } - private static TValue ParseValue(string value) + private TValue ParseValue(string value) { - return value.TryConvertTo(out var ret) + return value.TryConvertTo(CultureInfo, out var ret) ? ret : throw new InvalidOperationException($"Unsupported type {typeof(TValue)}"); } @@ -338,7 +352,18 @@ protected override bool TryParseValueFromString(string value, [MaybeNullWhen(fal } else { - ret = base.TryParseValueFromString(value, out result, out validationErrorMessage); + if (Parser != null) + { + var parsedValue = Parser(value, CultureInfo); + ret = parsedValue.Success; + result = ret ? parsedValue.Value! : default; + } + else + { + ret = value.TryConvertTo(CultureInfo, out result); + } + + validationErrorMessage = ret ? null : FormatParsingErrorMessage(); if (ret && UseInputEvent) { _lastInputValueString = value; diff --git a/src/BootstrapBlazor/Extensions/ObjectExtensions.cs b/src/BootstrapBlazor/Extensions/ObjectExtensions.cs index 4a9eecf76cc..03a786044bd 100644 --- a/src/BootstrapBlazor/Extensions/ObjectExtensions.cs +++ b/src/BootstrapBlazor/Extensions/ObjectExtensions.cs @@ -206,6 +206,63 @@ public static bool TryConvertTo(this string? source, [MaybeNullWhen(fals return ret; } + /// + /// 使用指定文化尝试将字符串表示的值转换为指定类型 + /// Tries to convert the string representation of a value to a specified type using the specified culture + /// + /// + /// + /// + /// + public static bool TryConvertTo(this string? source, CultureInfo culture, [MaybeNullWhen(false)] out TValue val) + { + var type = Nullable.GetUnderlyingType(typeof(TValue)) ?? typeof(TValue); + if (type == typeof(string)) + { + val = (TValue)(object)source!; + return true; + } + + if (source == null) + { + val = default!; + return true; + } + + if (source.Length == 0 || !type.IsNumber()) + { + var value = type == typeof(bool) + ? (object)source.Equals("true", StringComparison.CurrentCultureIgnoreCase) + : source; + return BindConverter.TryConvertTo(value, culture, out val); + } + + object? converted = Type.GetTypeCode(type) switch + { + TypeCode.SByte => sbyte.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.Byte => byte.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.Int16 => short.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.UInt16 => ushort.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.Int32 => int.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.UInt32 => uint.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.Int64 => long.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.UInt64 => ulong.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.Single => float.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.Double => double.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.Decimal => decimal.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + _ => null + }; + + if (converted != null) + { + val = (TValue)converted; + return true; + } + + val = default; + return false; + } + /// /// 将文件大小格式化为带有适当单位的字符串 /// Formats the file size into a string with appropriate units diff --git a/test/UnitTest/Components/InputNumberTest.cs b/test/UnitTest/Components/InputNumberTest.cs index 4458f3b3579..3a088758658 100644 --- a/test/UnitTest/Components/InputNumberTest.cs +++ b/test/UnitTest/Components/InputNumberTest.cs @@ -108,6 +108,86 @@ public void Formatter_Ok() cut.InvokeAsync(() => input.Change("")); } + [Fact] + public async Task Formatter_Culture_Ok() + { + var value = 2m; + var culture = CultureInfo.GetCultureInfo("en-US"); + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, value); + pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => value = v)); + pb.Add(a => a.CultureInfo, culture); + pb.Add(a => a.Formatter, v => v.ToString("C0", culture)); + }); + + var input = cut.Find("input"); + Assert.Equal("$2", input.GetAttribute("value")); + + await cut.InvokeAsync(() => input.Change("$ 3")); + Assert.Equal(3m, value); + } + + [Fact] + public async Task Parser_Success_Culture_Ok() + { + var value = 2; + CultureInfo? parserCulture = null; + var culture = CultureInfo.GetCultureInfo("fr-FR"); + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, value); + pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => value = v)); + pb.Add(a => a.CultureInfo, culture); + pb.Add(a => a.UseInputEvent, true); + pb.Add(a => a.Parser, (text, currentCulture) => + { + parserCulture = currentCulture; + return (true, text.Length); + }); + }); + + var input = cut.Find("input"); + await cut.InvokeAsync(() => input.Input("custom")); + + Assert.Equal(6, value); + Assert.Same(culture, parserCulture); + Assert.Equal("custom", input.GetAttribute("value")); + } + + [Fact] + public async Task Parser_Failure_DoesNotFallback_Ok() + { + var value = 2; + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, value); + pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => value = v)); + pb.Add(a => a.Parser, (_, _) => (false, default)); + }); + + await cut.InvokeAsync(() => cut.Find("input").Change("3")); + + Assert.Equal(2, value); + } + + [Fact] + public async Task Parser_Null_UsesDefaultConversion_Ok() + { + var value = 2m; + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, value); + pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => value = v)); + pb.Add(a => a.CultureInfo, CultureInfo.GetCultureInfo("en-US")); + pb.Add(a => a.Parser, null); + }); + + await cut.InvokeAsync(() => cut.Find("input").Change("$ 3")); + + Assert.Equal(3m, value); + } + [Fact] public void Formatter_Null() { diff --git a/test/UnitTest/Extensions/ObjectExtensionsTest.cs b/test/UnitTest/Extensions/ObjectExtensionsTest.cs index 387a135524a..ca8e634fe34 100644 --- a/test/UnitTest/Extensions/ObjectExtensionsTest.cs +++ b/test/UnitTest/Extensions/ObjectExtensionsTest.cs @@ -164,6 +164,35 @@ public static void TryConvertTo_Generic() Assert.True(result); } + [Fact] + public static void TryConvertTo_GenericCulture() + { + var culture = CultureInfo.GetCultureInfo("en-US"); + + Assert.True("$ 2".TryConvertTo(culture, out var doubleValue)); + Assert.Equal(2d, doubleValue); + + Assert.True("$1,234.50".TryConvertTo(culture, out var decimalValue)); + Assert.Equal(1234.50m, decimalValue); + + Assert.True("$12,345,678,901,234,567,890.123456789".TryConvertTo(culture, out var preciseDecimalValue)); + Assert.Equal(12345678901234567890.123456789m, preciseDecimalValue); + + Assert.True("$ 2".TryConvertTo(culture, out var nullableDecimalValue)); + Assert.Equal(2m, nullableDecimalValue); + + Assert.True("1,234".TryConvertTo(culture, out var integerValue)); + Assert.Equal(1234, integerValue); + + Assert.True("18,446,744,073,709,551,615".TryConvertTo(culture, out var unsignedValue)); + Assert.Equal(ulong.MaxValue, unsignedValue); + + Assert.True("false".TryConvertTo(culture, out var booleanValue)); + Assert.False(booleanValue); + + Assert.False("not-a-number".TryConvertTo(culture, out _)); + } + [Theory] [InlineData(100f, "100 B")] [InlineData(1024f, "1.0 KB")] From c4513469875b69e8cea8c1820dc5970e97e84bb5 Mon Sep 17 00:00:00 2001 From: Tony-ST0754 <6914529@qq.com> Date: Sat, 5 Sep 2026 18:12:31 +0800 Subject: [PATCH 2/6] =?UTF-8?q?test:=E8=A1=A5=E5=85=85=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=AE=8C=E5=96=84=E4=BB=A3=E7=A0=81=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Extensions/ObjectExtensions.cs | 3 +- .../Components/TableNumberFilterTest.cs | 2 +- .../Extensions/ObjectExtensionsTest.cs | 133 +++++++++++++++--- 3 files changed, 119 insertions(+), 19 deletions(-) diff --git a/src/BootstrapBlazor/Extensions/ObjectExtensions.cs b/src/BootstrapBlazor/Extensions/ObjectExtensions.cs index 03a786044bd..380cc8ca98c 100644 --- a/src/BootstrapBlazor/Extensions/ObjectExtensions.cs +++ b/src/BootstrapBlazor/Extensions/ObjectExtensions.cs @@ -249,8 +249,7 @@ public static bool TryConvertTo(this string? source, CultureInfo culture TypeCode.UInt64 => ulong.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, TypeCode.Single => float.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, TypeCode.Double => double.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, - TypeCode.Decimal => decimal.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, - _ => null + _ => decimal.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null }; if (converted != null) diff --git a/test/UnitTest/Components/TableNumberFilterTest.cs b/test/UnitTest/Components/TableNumberFilterTest.cs index 206bb4c5ae7..d7fee064711 100644 --- a/test/UnitTest/Components/TableNumberFilterTest.cs +++ b/test/UnitTest/Components/TableNumberFilterTest.cs @@ -17,7 +17,7 @@ public async Task OnFilterAsync_Ok() pb.Add(a => a.IsHeaderRow, true); }); - var input = cut.Find("[type=\"number\"]"); + var input = cut.Find("[inputmode=\"decimal\"]"); await cut.InvokeAsync(() => input.Change("1")); var action = cut.Find(".dropdown-item"); diff --git a/test/UnitTest/Extensions/ObjectExtensionsTest.cs b/test/UnitTest/Extensions/ObjectExtensionsTest.cs index ca8e634fe34..543b1b605d5 100644 --- a/test/UnitTest/Extensions/ObjectExtensionsTest.cs +++ b/test/UnitTest/Extensions/ObjectExtensionsTest.cs @@ -165,32 +165,121 @@ public static void TryConvertTo_Generic() } [Fact] - public static void TryConvertTo_GenericCulture() + public static void TryConvertTo_GenericCulture_StringNullAndEmpty() + { + var culture = CultureInfo.InvariantCulture; + + Assert.True("test".TryConvertTo(culture, out var text)); + Assert.Equal("test", text); + + Assert.True(((string?)null).TryConvertTo(culture, out var nullText)); + Assert.Null(nullText); + + Assert.True(((string?)null).TryConvertTo(culture, out var nullInteger)); + Assert.Equal(0, nullInteger); + + Assert.True(((string?)null).TryConvertTo(culture, out var nullNullableInteger)); + Assert.Null(nullNullableInteger); + + Assert.False(string.Empty.TryConvertTo(culture, out var emptyInteger)); + Assert.Equal(0, emptyInteger); + + Assert.True(string.Empty.TryConvertTo(culture, out var emptyNullableInteger)); + Assert.Null(emptyNullableInteger); + } + + [Theory] + [InlineData("true", true)] + [InlineData("TRUE", true)] + [InlineData("false", false)] + [InlineData("False", false)] + public static void TryConvertTo_GenericCulture_Boolean(string source, bool expected) + { + Assert.True(source.TryConvertTo(CultureInfo.InvariantCulture, out var actual)); + Assert.Equal(expected, actual); + } + + [Fact] + public static void TryConvertTo_GenericCulture_NumericBoundaries() + { + var culture = CultureInfo.InvariantCulture; + + AssertConversion("-128", culture, sbyte.MinValue); + AssertConversion("255", culture, byte.MaxValue); + AssertConversion("-32768", culture, short.MinValue); + AssertConversion("65535", culture, ushort.MaxValue); + AssertConversion("-2147483648", culture, int.MinValue); + AssertConversion("4294967295", culture, uint.MaxValue); + AssertConversion("-9223372036854775808", culture, long.MinValue); + AssertConversion("18446744073709551615", culture, ulong.MaxValue); + AssertConversion("-3.4028235E+38", culture, float.MinValue); + AssertConversion("1.7976931348623157E+308", culture, double.MaxValue); + AssertConversion("79228162514264337593543950335", culture, decimal.MaxValue); + } + + [Fact] + public static void TryConvertTo_GenericCulture_NumericFailuresReturnDefault() + { + var culture = CultureInfo.InvariantCulture; + + AssertConversionFails("-129", culture); + AssertConversionFails("256", culture); + AssertConversionFails("-32769", culture); + AssertConversionFails("65536", culture); + AssertConversionFails("-2147483649", culture); + AssertConversionFails("4294967296", culture); + AssertConversionFails("-9223372036854775809", culture); + AssertConversionFails("18446744073709551616", culture); + AssertConversionFails("not-a-number", culture); + AssertConversionFails("not-a-number", culture); + AssertConversionFails("79228162514264337593543950336", culture); + } + + [Fact] + public static void TryConvertTo_GenericCulture_NullableNumeric() { var culture = CultureInfo.GetCultureInfo("en-US"); - Assert.True("$ 2".TryConvertTo(culture, out var doubleValue)); - Assert.Equal(2d, doubleValue); + AssertConversion("$ 2", culture, 2m); + AssertConversion("1,234", culture, 1234); + } + + [Fact] + public static void TryConvertTo_GenericCulture_NumberFormats() + { + var enUs = CultureInfo.GetCultureInfo("en-US"); + AssertConversion("$ 2", enUs, 2d); + AssertConversion("$1,234.50", enUs, 1234.50m); + + var deDe = CultureInfo.GetCultureInfo("de-DE"); + AssertConversion("1.234,5", deDe, 1234.5d); - Assert.True("$1,234.50".TryConvertTo(culture, out var decimalValue)); - Assert.Equal(1234.50m, decimalValue); + var frFr = CultureInfo.GetCultureInfo("fr-FR"); + AssertConversion(1234.5m.ToString("N2", frFr), frFr, 1234.5m); + } - Assert.True("$12,345,678,901,234,567,890.123456789".TryConvertTo(culture, out var preciseDecimalValue)); - Assert.Equal(12345678901234567890.123456789m, preciseDecimalValue); + [Fact] + public static void TryConvertTo_GenericCulture_PreservesPrecision() + { + var culture = CultureInfo.GetCultureInfo("en-US"); - Assert.True("$ 2".TryConvertTo(culture, out var nullableDecimalValue)); - Assert.Equal(2m, nullableDecimalValue); + AssertConversion("$12,345,678,901,234,567,890.123456789", culture, 12345678901234567890.123456789m); + AssertConversion("18,446,744,073,709,551,615", culture, ulong.MaxValue); + } - Assert.True("1,234".TryConvertTo(culture, out var integerValue)); - Assert.Equal(1234, integerValue); + [Fact] + public static void TryConvertTo_GenericCulture_NonNumericUsesBindConverter() + { + var culture = CultureInfo.InvariantCulture; + var guid = Guid.NewGuid(); - Assert.True("18,446,744,073,709,551,615".TryConvertTo(culture, out var unsignedValue)); - Assert.Equal(ulong.MaxValue, unsignedValue); + AssertConversion(guid.ToString(), culture, guid); - Assert.True("false".TryConvertTo(culture, out var booleanValue)); - Assert.False(booleanValue); + Assert.False("not-a-date".TryConvertTo(culture, out var invalidDate)); + Assert.Equal(default, invalidDate); - Assert.False("not-a-number".TryConvertTo(culture, out _)); + var deDe = CultureInfo.GetCultureInfo("de-DE"); + AssertConversion("31.12.2025", deDe, new DateTime(2025, 12, 31)); } [Theory] @@ -401,6 +490,18 @@ private interface MockInterface string? Name { get; set; } } + private static void AssertConversion(string source, CultureInfo culture, TValue expected) + { + Assert.True(source.TryConvertTo(culture, out var actual)); + Assert.Equal(expected, actual); + } + + private static void AssertConversionFails(string source, CultureInfo culture) + { + Assert.False(source.TryConvertTo(culture, out var actual)); + Assert.Equal(default, actual); + } + private class MockComplexObject { public Foo? Foo { get; set; } From d955dc988b45afccf1c143dd9d5454b05e0996cc Mon Sep 17 00:00:00 2001 From: Tony-ST0754 <6914529@qq.com> Date: Sun, 6 Sep 2026 12:51:08 +0800 Subject: [PATCH 3/6] =?UTF-8?q?Revert=20"test:=E8=A1=A5=E5=85=85=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95=E5=AE=8C=E5=96=84=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E7=8E=87"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit c4513469875b69e8cea8c1820dc5970e97e84bb5. --- .../Extensions/ObjectExtensions.cs | 3 +- .../Components/TableNumberFilterTest.cs | 2 +- .../Extensions/ObjectExtensionsTest.cs | 133 +++--------------- 3 files changed, 19 insertions(+), 119 deletions(-) diff --git a/src/BootstrapBlazor/Extensions/ObjectExtensions.cs b/src/BootstrapBlazor/Extensions/ObjectExtensions.cs index 380cc8ca98c..03a786044bd 100644 --- a/src/BootstrapBlazor/Extensions/ObjectExtensions.cs +++ b/src/BootstrapBlazor/Extensions/ObjectExtensions.cs @@ -249,7 +249,8 @@ public static bool TryConvertTo(this string? source, CultureInfo culture TypeCode.UInt64 => ulong.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, TypeCode.Single => float.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, TypeCode.Double => double.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, - _ => decimal.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null + TypeCode.Decimal => decimal.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + _ => null }; if (converted != null) diff --git a/test/UnitTest/Components/TableNumberFilterTest.cs b/test/UnitTest/Components/TableNumberFilterTest.cs index d7fee064711..206bb4c5ae7 100644 --- a/test/UnitTest/Components/TableNumberFilterTest.cs +++ b/test/UnitTest/Components/TableNumberFilterTest.cs @@ -17,7 +17,7 @@ public async Task OnFilterAsync_Ok() pb.Add(a => a.IsHeaderRow, true); }); - var input = cut.Find("[inputmode=\"decimal\"]"); + var input = cut.Find("[type=\"number\"]"); await cut.InvokeAsync(() => input.Change("1")); var action = cut.Find(".dropdown-item"); diff --git a/test/UnitTest/Extensions/ObjectExtensionsTest.cs b/test/UnitTest/Extensions/ObjectExtensionsTest.cs index 543b1b605d5..ca8e634fe34 100644 --- a/test/UnitTest/Extensions/ObjectExtensionsTest.cs +++ b/test/UnitTest/Extensions/ObjectExtensionsTest.cs @@ -165,121 +165,32 @@ public static void TryConvertTo_Generic() } [Fact] - public static void TryConvertTo_GenericCulture_StringNullAndEmpty() - { - var culture = CultureInfo.InvariantCulture; - - Assert.True("test".TryConvertTo(culture, out var text)); - Assert.Equal("test", text); - - Assert.True(((string?)null).TryConvertTo(culture, out var nullText)); - Assert.Null(nullText); - - Assert.True(((string?)null).TryConvertTo(culture, out var nullInteger)); - Assert.Equal(0, nullInteger); - - Assert.True(((string?)null).TryConvertTo(culture, out var nullNullableInteger)); - Assert.Null(nullNullableInteger); - - Assert.False(string.Empty.TryConvertTo(culture, out var emptyInteger)); - Assert.Equal(0, emptyInteger); - - Assert.True(string.Empty.TryConvertTo(culture, out var emptyNullableInteger)); - Assert.Null(emptyNullableInteger); - } - - [Theory] - [InlineData("true", true)] - [InlineData("TRUE", true)] - [InlineData("false", false)] - [InlineData("False", false)] - public static void TryConvertTo_GenericCulture_Boolean(string source, bool expected) - { - Assert.True(source.TryConvertTo(CultureInfo.InvariantCulture, out var actual)); - Assert.Equal(expected, actual); - } - - [Fact] - public static void TryConvertTo_GenericCulture_NumericBoundaries() - { - var culture = CultureInfo.InvariantCulture; - - AssertConversion("-128", culture, sbyte.MinValue); - AssertConversion("255", culture, byte.MaxValue); - AssertConversion("-32768", culture, short.MinValue); - AssertConversion("65535", culture, ushort.MaxValue); - AssertConversion("-2147483648", culture, int.MinValue); - AssertConversion("4294967295", culture, uint.MaxValue); - AssertConversion("-9223372036854775808", culture, long.MinValue); - AssertConversion("18446744073709551615", culture, ulong.MaxValue); - AssertConversion("-3.4028235E+38", culture, float.MinValue); - AssertConversion("1.7976931348623157E+308", culture, double.MaxValue); - AssertConversion("79228162514264337593543950335", culture, decimal.MaxValue); - } - - [Fact] - public static void TryConvertTo_GenericCulture_NumericFailuresReturnDefault() - { - var culture = CultureInfo.InvariantCulture; - - AssertConversionFails("-129", culture); - AssertConversionFails("256", culture); - AssertConversionFails("-32769", culture); - AssertConversionFails("65536", culture); - AssertConversionFails("-2147483649", culture); - AssertConversionFails("4294967296", culture); - AssertConversionFails("-9223372036854775809", culture); - AssertConversionFails("18446744073709551616", culture); - AssertConversionFails("not-a-number", culture); - AssertConversionFails("not-a-number", culture); - AssertConversionFails("79228162514264337593543950336", culture); - } - - [Fact] - public static void TryConvertTo_GenericCulture_NullableNumeric() + public static void TryConvertTo_GenericCulture() { var culture = CultureInfo.GetCultureInfo("en-US"); - AssertConversion("$ 2", culture, 2m); - AssertConversion("1,234", culture, 1234); - } - - [Fact] - public static void TryConvertTo_GenericCulture_NumberFormats() - { - var enUs = CultureInfo.GetCultureInfo("en-US"); - AssertConversion("$ 2", enUs, 2d); - AssertConversion("$1,234.50", enUs, 1234.50m); - - var deDe = CultureInfo.GetCultureInfo("de-DE"); - AssertConversion("1.234,5", deDe, 1234.5d); + Assert.True("$ 2".TryConvertTo(culture, out var doubleValue)); + Assert.Equal(2d, doubleValue); - var frFr = CultureInfo.GetCultureInfo("fr-FR"); - AssertConversion(1234.5m.ToString("N2", frFr), frFr, 1234.5m); - } + Assert.True("$1,234.50".TryConvertTo(culture, out var decimalValue)); + Assert.Equal(1234.50m, decimalValue); - [Fact] - public static void TryConvertTo_GenericCulture_PreservesPrecision() - { - var culture = CultureInfo.GetCultureInfo("en-US"); + Assert.True("$12,345,678,901,234,567,890.123456789".TryConvertTo(culture, out var preciseDecimalValue)); + Assert.Equal(12345678901234567890.123456789m, preciseDecimalValue); - AssertConversion("$12,345,678,901,234,567,890.123456789", culture, 12345678901234567890.123456789m); - AssertConversion("18,446,744,073,709,551,615", culture, ulong.MaxValue); - } + Assert.True("$ 2".TryConvertTo(culture, out var nullableDecimalValue)); + Assert.Equal(2m, nullableDecimalValue); - [Fact] - public static void TryConvertTo_GenericCulture_NonNumericUsesBindConverter() - { - var culture = CultureInfo.InvariantCulture; - var guid = Guid.NewGuid(); + Assert.True("1,234".TryConvertTo(culture, out var integerValue)); + Assert.Equal(1234, integerValue); - AssertConversion(guid.ToString(), culture, guid); + Assert.True("18,446,744,073,709,551,615".TryConvertTo(culture, out var unsignedValue)); + Assert.Equal(ulong.MaxValue, unsignedValue); - Assert.False("not-a-date".TryConvertTo(culture, out var invalidDate)); - Assert.Equal(default, invalidDate); + Assert.True("false".TryConvertTo(culture, out var booleanValue)); + Assert.False(booleanValue); - var deDe = CultureInfo.GetCultureInfo("de-DE"); - AssertConversion("31.12.2025", deDe, new DateTime(2025, 12, 31)); + Assert.False("not-a-number".TryConvertTo(culture, out _)); } [Theory] @@ -490,18 +401,6 @@ private interface MockInterface string? Name { get; set; } } - private static void AssertConversion(string source, CultureInfo culture, TValue expected) - { - Assert.True(source.TryConvertTo(culture, out var actual)); - Assert.Equal(expected, actual); - } - - private static void AssertConversionFails(string source, CultureInfo culture) - { - Assert.False(source.TryConvertTo(culture, out var actual)); - Assert.Equal(default, actual); - } - private class MockComplexObject { public Foo? Foo { get; set; } From 082cec6f3fa158207f8b21037055bc3acdd0fcc4 Mon Sep 17 00:00:00 2001 From: Tony-ST0754 <6914529@qq.com> Date: Sun, 6 Sep 2026 12:51:14 +0800 Subject: [PATCH 4/6] =?UTF-8?q?Revert=20"feat(InputNumber):=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E6=95=B0=E5=AD=97=E6=A0=BC=E5=BC=8F=E5=8C=96=E6=98=BE?= =?UTF-8?q?=E7=A4=BA"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 94ebc58668ed4b0fc28d77821270e296c4d89c9f. --- .../Components/Samples/InputNumbers.razor | 51 ++++++------ .../Components/Samples/InputNumbers.razor.cs | 27 +------ src/BootstrapBlazor.Server/appsettings.json | 6 +- .../InputNumber/BootstrapInputNumber.razor | 4 +- .../InputNumber/BootstrapInputNumber.razor.cs | 31 +------ .../Extensions/ObjectExtensions.cs | 57 ------------- test/UnitTest/Components/InputNumberTest.cs | 80 ------------------- .../Extensions/ObjectExtensionsTest.cs | 29 ------- 8 files changed, 34 insertions(+), 251 deletions(-) diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor b/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor index d19b8a6dd82..d7e0a5be2e1 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor +++ b/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor @@ -5,7 +5,7 @@

@Localizer["InputNumbersDescription"]

-@*
@@ -19,27 +19,25 @@
-*@ +
- +
- +
- +
- @*
-
+
@@ -50,11 +48,11 @@
-
*@ +
-@*

@Localizer["InputNumbersShowButtonDescription"]

@@ -69,9 +67,9 @@
-
*@ + -@*
@@ -113,12 +111,12 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se

@Localizer["InputNumbersStep0.1"]

-
+ -

@Localizer["InputNumbersColorDescription1"]

+

@Localizer["InputNumbersColorDescription1"]

@@ -163,7 +161,7 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se

@Localizer["InputNumbersColorDescription2"]

-
+
@@ -192,10 +190,10 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se
-
+
- +
@@ -204,13 +202,13 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se
-*@ +
- @*
+
@@ -227,25 +225,23 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se
- +
-
*@ +
- +
- +
-@*
@@ -256,7 +252,7 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se
-
*@ + - diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs b/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs index 323a033a871..a1f831f0029 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs +++ b/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs @@ -1,11 +1,8 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the Apache 2.0 License // See the LICENSE file in the project root for more information. // Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone -using System.Globalization; -using System.Text.RegularExpressions; - namespace BootstrapBlazor.Server.Components.Samples; /// @@ -54,27 +51,9 @@ public sealed partial class InputNumbers private double? InputNullableValue { get; set; } = 12.01; - private decimal InputValue { get; set; } = 12.01m; + private double InputValue { get; set; } = 12.01; - private double BindInputNullableValue { get; set; } = 2; + private int? BindInputNullableValue { get; set; } = 2; private int BindInputValue { get; set; } = 2; - - private string Format1(double value) - { - double.TryParse("$235,623.235", NumberStyles.Any, CultureInfo.GetCultureInfo("en-US"), out var number); - double.TryParse("¥1235,623.235", NumberStyles.Currency, CultureInfo.GetCultureInfo("zh-CN"), out var number1); - return $"$ {value.ToString("n0")}"; - } - - private string Format2(decimal value) - { - return $"{value.ToString("n2")}%"; - } - - private (bool Success, decimal Value) Parser1(string arg1, CultureInfo info) - { - var ret = (true, decimal.Parse(arg1.Replace("%", ""), info)); - return ret; - } } diff --git a/src/BootstrapBlazor.Server/appsettings.json b/src/BootstrapBlazor.Server/appsettings.json index 7f18fdb96c5..695457f9d91 100644 --- a/src/BootstrapBlazor.Server/appsettings.json +++ b/src/BootstrapBlazor.Server/appsettings.json @@ -50,9 +50,9 @@ "Short": "1", "Int": "1", "Long": "1", - "Float": "any", - "Double": "any", - "Decimal": "any" + "Float": "0.1", + "Double": "0.01", + "Decimal": "0.01" }, "ConnectionHubOptions": { "Enable": true, diff --git a/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor b/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor index b6960d717b5..61e479c3ea5 100644 --- a/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor +++ b/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor @@ -1,4 +1,4 @@ -@namespace BootstrapBlazor.Components +@namespace BootstrapBlazor.Components @typeparam TValue @inherits BootstrapInputNumberBase @@ -25,5 +25,5 @@ else @code { RenderFragment RenderInput => - @; + @; } diff --git a/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor.cs b/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor.cs index c221f3f01f9..0b73672aaec 100644 --- a/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor.cs +++ b/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor.cs @@ -93,20 +93,6 @@ public partial class BootstrapInputNumber [Parameter] public string? PlusIcon { get; set; } - /// - /// 获得/设置 数值解析使用的文化信息 - /// Gets or sets the culture used to parse numeric values - /// - [Parameter] - public CultureInfo CultureInfo { get; set; } = CultureInfo.CurrentCulture; - - /// - /// 获得/设置 自定义数值解析回调方法 - /// Gets or sets the callback used to parse custom numeric values - /// - [Parameter] - public Func? Parser { get; set; } - [Inject] [NotNull] private IStringLocalizer>? Localizer { get; set; } @@ -215,9 +201,9 @@ private string GetStepString() return StepString; } - private TValue ParseValue(string value) + private static TValue ParseValue(string value) { - return value.TryConvertTo(CultureInfo, out var ret) + return value.TryConvertTo(out var ret) ? ret : throw new InvalidOperationException($"Unsupported type {typeof(TValue)}"); } @@ -352,18 +338,7 @@ protected override bool TryParseValueFromString(string value, [MaybeNullWhen(fal } else { - if (Parser != null) - { - var parsedValue = Parser(value, CultureInfo); - ret = parsedValue.Success; - result = ret ? parsedValue.Value! : default; - } - else - { - ret = value.TryConvertTo(CultureInfo, out result); - } - - validationErrorMessage = ret ? null : FormatParsingErrorMessage(); + ret = base.TryParseValueFromString(value, out result, out validationErrorMessage); if (ret && UseInputEvent) { _lastInputValueString = value; diff --git a/src/BootstrapBlazor/Extensions/ObjectExtensions.cs b/src/BootstrapBlazor/Extensions/ObjectExtensions.cs index 03a786044bd..4a9eecf76cc 100644 --- a/src/BootstrapBlazor/Extensions/ObjectExtensions.cs +++ b/src/BootstrapBlazor/Extensions/ObjectExtensions.cs @@ -206,63 +206,6 @@ public static bool TryConvertTo(this string? source, [MaybeNullWhen(fals return ret; } - /// - /// 使用指定文化尝试将字符串表示的值转换为指定类型 - /// Tries to convert the string representation of a value to a specified type using the specified culture - /// - /// - /// - /// - /// - public static bool TryConvertTo(this string? source, CultureInfo culture, [MaybeNullWhen(false)] out TValue val) - { - var type = Nullable.GetUnderlyingType(typeof(TValue)) ?? typeof(TValue); - if (type == typeof(string)) - { - val = (TValue)(object)source!; - return true; - } - - if (source == null) - { - val = default!; - return true; - } - - if (source.Length == 0 || !type.IsNumber()) - { - var value = type == typeof(bool) - ? (object)source.Equals("true", StringComparison.CurrentCultureIgnoreCase) - : source; - return BindConverter.TryConvertTo(value, culture, out val); - } - - object? converted = Type.GetTypeCode(type) switch - { - TypeCode.SByte => sbyte.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, - TypeCode.Byte => byte.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, - TypeCode.Int16 => short.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, - TypeCode.UInt16 => ushort.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, - TypeCode.Int32 => int.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, - TypeCode.UInt32 => uint.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, - TypeCode.Int64 => long.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, - TypeCode.UInt64 => ulong.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, - TypeCode.Single => float.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, - TypeCode.Double => double.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, - TypeCode.Decimal => decimal.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, - _ => null - }; - - if (converted != null) - { - val = (TValue)converted; - return true; - } - - val = default; - return false; - } - /// /// 将文件大小格式化为带有适当单位的字符串 /// Formats the file size into a string with appropriate units diff --git a/test/UnitTest/Components/InputNumberTest.cs b/test/UnitTest/Components/InputNumberTest.cs index 3a088758658..4458f3b3579 100644 --- a/test/UnitTest/Components/InputNumberTest.cs +++ b/test/UnitTest/Components/InputNumberTest.cs @@ -108,86 +108,6 @@ public void Formatter_Ok() cut.InvokeAsync(() => input.Change("")); } - [Fact] - public async Task Formatter_Culture_Ok() - { - var value = 2m; - var culture = CultureInfo.GetCultureInfo("en-US"); - var cut = Context.Render>(pb => - { - pb.Add(a => a.Value, value); - pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => value = v)); - pb.Add(a => a.CultureInfo, culture); - pb.Add(a => a.Formatter, v => v.ToString("C0", culture)); - }); - - var input = cut.Find("input"); - Assert.Equal("$2", input.GetAttribute("value")); - - await cut.InvokeAsync(() => input.Change("$ 3")); - Assert.Equal(3m, value); - } - - [Fact] - public async Task Parser_Success_Culture_Ok() - { - var value = 2; - CultureInfo? parserCulture = null; - var culture = CultureInfo.GetCultureInfo("fr-FR"); - var cut = Context.Render>(pb => - { - pb.Add(a => a.Value, value); - pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => value = v)); - pb.Add(a => a.CultureInfo, culture); - pb.Add(a => a.UseInputEvent, true); - pb.Add(a => a.Parser, (text, currentCulture) => - { - parserCulture = currentCulture; - return (true, text.Length); - }); - }); - - var input = cut.Find("input"); - await cut.InvokeAsync(() => input.Input("custom")); - - Assert.Equal(6, value); - Assert.Same(culture, parserCulture); - Assert.Equal("custom", input.GetAttribute("value")); - } - - [Fact] - public async Task Parser_Failure_DoesNotFallback_Ok() - { - var value = 2; - var cut = Context.Render>(pb => - { - pb.Add(a => a.Value, value); - pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => value = v)); - pb.Add(a => a.Parser, (_, _) => (false, default)); - }); - - await cut.InvokeAsync(() => cut.Find("input").Change("3")); - - Assert.Equal(2, value); - } - - [Fact] - public async Task Parser_Null_UsesDefaultConversion_Ok() - { - var value = 2m; - var cut = Context.Render>(pb => - { - pb.Add(a => a.Value, value); - pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => value = v)); - pb.Add(a => a.CultureInfo, CultureInfo.GetCultureInfo("en-US")); - pb.Add(a => a.Parser, null); - }); - - await cut.InvokeAsync(() => cut.Find("input").Change("$ 3")); - - Assert.Equal(3m, value); - } - [Fact] public void Formatter_Null() { diff --git a/test/UnitTest/Extensions/ObjectExtensionsTest.cs b/test/UnitTest/Extensions/ObjectExtensionsTest.cs index ca8e634fe34..387a135524a 100644 --- a/test/UnitTest/Extensions/ObjectExtensionsTest.cs +++ b/test/UnitTest/Extensions/ObjectExtensionsTest.cs @@ -164,35 +164,6 @@ public static void TryConvertTo_Generic() Assert.True(result); } - [Fact] - public static void TryConvertTo_GenericCulture() - { - var culture = CultureInfo.GetCultureInfo("en-US"); - - Assert.True("$ 2".TryConvertTo(culture, out var doubleValue)); - Assert.Equal(2d, doubleValue); - - Assert.True("$1,234.50".TryConvertTo(culture, out var decimalValue)); - Assert.Equal(1234.50m, decimalValue); - - Assert.True("$12,345,678,901,234,567,890.123456789".TryConvertTo(culture, out var preciseDecimalValue)); - Assert.Equal(12345678901234567890.123456789m, preciseDecimalValue); - - Assert.True("$ 2".TryConvertTo(culture, out var nullableDecimalValue)); - Assert.Equal(2m, nullableDecimalValue); - - Assert.True("1,234".TryConvertTo(culture, out var integerValue)); - Assert.Equal(1234, integerValue); - - Assert.True("18,446,744,073,709,551,615".TryConvertTo(culture, out var unsignedValue)); - Assert.Equal(ulong.MaxValue, unsignedValue); - - Assert.True("false".TryConvertTo(culture, out var booleanValue)); - Assert.False(booleanValue); - - Assert.False("not-a-number".TryConvertTo(culture, out _)); - } - [Theory] [InlineData(100f, "100 B")] [InlineData(1024f, "1.0 KB")] From 270cd5335b52f3358e475a2cfbbe31661d1a5f0f Mon Sep 17 00:00:00 2001 From: Tony-ST0754 <6914529@qq.com> Date: Sun, 6 Sep 2026 18:28:27 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat:=E6=96=B0=E5=A2=9EInputCurrency?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat:新增InputCurrency组件,输入数值后进行格式化显示的组件 doc:新增InputCurrency组件对应文档 test:补充组件单元测试 --- localization/de.json | 3 + localization/es.json | 3 + localization/it.json | 3 + localization/km-KH.json | 3 + localization/pt-BR.json | 3 + localization/pt.json | 3 + localization/ru-RU.json | 3 + localization/th-TH.json | 3 + localization/uk-UA.json | 3 + localization/zh-TW.json | 3 + .../Components/Samples/InputCurrency.razor | 73 +++ .../Components/Samples/InputCurrency.razor.cs | 56 ++ .../Samples/InputCurrency.razor.css | 7 + .../Components/Samples/InputNumbers.razor.cs | 2 +- src/BootstrapBlazor.Server/Locales/en-US.json | 17 +- src/BootstrapBlazor.Server/Locales/zh-CN.json | 17 +- src/BootstrapBlazor.Server/docs.json | 1 + .../BootstrapInputCurrency.razor | 17 + .../BootstrapInputCurrency.razor.cs | 333 ++++++++++++ .../Extensions/ObjectExtensions.cs | 56 ++ src/BootstrapBlazor/Locales/en.json | 3 + src/BootstrapBlazor/Locales/zh.json | 3 + test/UnitTest/Components/InputCurrencyTest.cs | 491 ++++++++++++++++++ .../Extensions/ObjectExtensionsTest.cs | 130 +++++ 24 files changed, 1233 insertions(+), 3 deletions(-) create mode 100644 src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor create mode 100644 src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.cs create mode 100644 src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.css create mode 100644 src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor create mode 100644 src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs create mode 100644 test/UnitTest/Components/InputCurrencyTest.cs diff --git a/localization/de.json b/localization/de.json index 6c2c77dddaf..b35e87d2a9f 100644 --- a/localization/de.json +++ b/localization/de.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "Das Feld {0} muss eine Zahl sein." }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "Das Feld {0} muss eine Zahl sein." + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "Ja", "ButtonNoText": "Nein", diff --git a/localization/es.json b/localization/es.json index 47fbc140ca9..d00078ac86f 100644 --- a/localization/es.json +++ b/localization/es.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "El campo {0} debe ser un número." }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "El campo {0} debe ser un número." + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "Sí", "ButtonNoText": "No", diff --git a/localization/it.json b/localization/it.json index c94983047d9..2b4d6669470 100644 --- a/localization/it.json +++ b/localization/it.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "Il campo {0} deve essere un numero." }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "Il campo {0} deve essere un numero." + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "Sì", "ButtonNoText": "No", diff --git a/localization/km-KH.json b/localization/km-KH.json index 92c92e067d2..be18274346b 100644 --- a/localization/km-KH.json +++ b/localization/km-KH.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "ទិន្នន័យ {0} ត្រូវ​តែ​ជា​លេខ។" }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "ទិន្នន័យ {0} ត្រូវ​តែ​ជា​លេខ។" + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "បាទ/ចាស៎", "ButtonNoText": "ទេ", diff --git a/localization/pt-BR.json b/localization/pt-BR.json index 1691acc82c6..5b26fd0668c 100644 --- a/localization/pt-BR.json +++ b/localization/pt-BR.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "O campo {0} deve ser um número." }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "O campo {0} deve ser um número." + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "Sim", "ButtonNoText": "Não", diff --git a/localization/pt.json b/localization/pt.json index f9deaab3207..b295e8c559a 100644 --- a/localization/pt.json +++ b/localization/pt.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "O campo {0} deve ser um número." }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "O campo {0} deve ser um número." + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "Sim", "ButtonNoText": "Não", diff --git a/localization/ru-RU.json b/localization/ru-RU.json index c84135954c6..5a06500b52b 100644 --- a/localization/ru-RU.json +++ b/localization/ru-RU.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "Поле {0} должно быть числом." }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "Поле {0} должно быть числом." + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "Да", "ButtonNoText": "Нет", diff --git a/localization/th-TH.json b/localization/th-TH.json index 8492e394c2a..6cb85f59a5f 100644 --- a/localization/th-TH.json +++ b/localization/th-TH.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "ฟิลด์ {0} ต้องเป็นตัวเลข" }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "ฟิลด์ {0} ต้องเป็นตัวเลข" + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "ใช่", "ButtonNoText": "ไม่ใช่", diff --git a/localization/uk-UA.json b/localization/uk-UA.json index 25d2f8488ce..02f9abb8ab9 100644 --- a/localization/uk-UA.json +++ b/localization/uk-UA.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "Поле {0} має бути числом." }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "Поле {0} має бути числом." + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "Так", "ButtonNoText": "Ні", diff --git a/localization/zh-TW.json b/localization/zh-TW.json index 2473b581046..7622f7ce3ff 100644 --- a/localization/zh-TW.json +++ b/localization/zh-TW.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "{0}欄位值必須為 Number 類型" }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "{0}欄位值必須為 Number 類型" + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "確認", "ButtonNoText": "取消", diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor b/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor new file mode 100644 index 00000000000..44cf2001727 --- /dev/null +++ b/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor @@ -0,0 +1,73 @@ +@page "/input-currency" + +@inject IStringLocalizer Localizer + +

@Localizer["InputCurrencyTitle"]

+

@Localizer["InputCurrencyDescription"]

+

@((MarkupString)Localizer["InputCurrencyMemo"].Value)

+ + +
+ +
+
+ + +
+
+ +
+
+ +
+
@((MarkupString)Localizer["InputCurrencyCultureIntro2"].Value)
+
+ +
+
+ +
+
+
+ + +
+
+ +
+
+ +
+
+
+ + +
+
+ +
+
+ +
+
+
+ + +
+
+ +
+
+
+ + diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.cs b/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.cs new file mode 100644 index 00000000000..c830cf20f66 --- /dev/null +++ b/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using System.Globalization; + +namespace BootstrapBlazor.Server.Components.Samples; + +public partial class InputCurrency +{ + /// + /// BindValue + /// + public double BindValue { get; set; } = 5; + + /// + /// + /// + public decimal BindDecimalValue { get; set; } = 1898.78m; + + /// + /// + /// + public double BindMaxMinValue { get; set; } = 9.23; + + /// + /// + /// + public double? NullableValue { get; set; } = 5; + + private string Format1(double value) + { + double.TryParse("$235,623.235", NumberStyles.Any, CultureInfo.GetCultureInfo("en-US"), out var number); + double.TryParse("¥1235,623.235", NumberStyles.Currency, CultureInfo.GetCultureInfo("zh-CN"), out var number1); + return $"$ {value.ToString("n0")}"; + } + private string Format(double? value) + { + if (value != null) + return $"$ {value?.ToString("n0")}"; + else + return null; + } + + private string FormatCustomize(decimal value) + { + return $"RMB {value.ToString("n2")}"; + } + + private (bool Success, decimal Value) Parser(string arg1, CultureInfo info) + { + var ret = (true, decimal.Parse(arg1.Replace("%", ""), info)); + return ret; + } +} diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.css b/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.css new file mode 100644 index 00000000000..f13df052c95 --- /dev/null +++ b/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.css @@ -0,0 +1,7 @@ +.demo-input-currency { + width: 200px; +} + + .demo-input-currency > ::deep .form-control { + width: inherit; + } diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs b/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs index a1f831f0029..a41936e123c 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs +++ b/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the Apache 2.0 License // See the LICENSE file in the project root for more information. // Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone diff --git a/src/BootstrapBlazor.Server/Locales/en-US.json b/src/BootstrapBlazor.Server/Locales/en-US.json index 780ed5a6a19..4e6f32a7465 100644 --- a/src/BootstrapBlazor.Server/Locales/en-US.json +++ b/src/BootstrapBlazor.Server/Locales/en-US.json @@ -2771,6 +2771,22 @@ "IntersectionObserverIntro": "By setting IsIntersectionObserver=\"true\" to enable lazy loading, the image will not be loaded when it is in the invisible area, and will be loaded only when it is about to be visible.", "IntersectionObserverTitle": "Intersection Observer" }, + "BootstrapBlazor.Server.Components.Samples.InputCurrency": { + "InputCurrencyCultureIntro": "Specify culture information, for example: en-US, zh-CN", + "InputCurrencyCultureIntro2": "Specify Brazilian Portuguese, for example: pt-BR", + "InputCurrencyCultureTitle": "Culture-specific usage", + "InputCurrencyCustomizeIntro": "Use the Formatter callback to achieve custom format usage", + "InputCurrencyCustomizeTitle": "Custom format usage", + "InputCurrencyDescription": "Only allows standard numeric values and provides formatting capabilities, for example: entering 12345.78 displays as 12,345.78 with thousand separators; as ¥12,345.78 for currency; and as 12,345.78% for percentage.", + "InputCurrencyMemo": "This component is actually an extension of the InputNumbers component, designed to meet the need to display formatted numeric values such as currency and percentages in the UI.", + "InputCurrencyNormalIntro": "Number numeric type displays a text box, automatically bringing up the numeric keyboard on mobile devices", + "InputCurrencyNormalTitle": "Basic usage", + "InputCurrencyPercentIntro": "You can use Formatter or FormatString to achieve percentage display", + "InputCurrencyPercentTitle": "Percentage usage", + "InputCurrencyRangeIntro": "Set Max and Min to control the numeric range 1-10", + "InputCurrencyRangeTitle": "Range limitation usage", + "InputCurrencyTitle": "InputCurrency" + }, "BootstrapBlazor.Server.Components.Samples.InputGroups": { "InputGroupsCheckboxIntro": "Add Checkbox or CheckboxList to the InputGroup", "InputGroupsCheckboxTitle": "Checkbox", @@ -6067,7 +6083,6 @@ "Picture.Required": "{0} is required" }, "BootstrapBlazor.Server.Components.Samples.ValidateForms": { - "AsyncValidationError": "The user name already exists.", "AsyncValidationUserName": "User name", "ChangeButtonText": "Change", "CustomValidationFormComment1": "Add mailbox validation rules", diff --git a/src/BootstrapBlazor.Server/Locales/zh-CN.json b/src/BootstrapBlazor.Server/Locales/zh-CN.json index 40735fdcb2c..3981912a05d 100644 --- a/src/BootstrapBlazor.Server/Locales/zh-CN.json +++ b/src/BootstrapBlazor.Server/Locales/zh-CN.json @@ -2771,6 +2771,22 @@ "IntersectionObserverIntro": "通过设置 IsIntersectionObserver=\"true\" 开启懒加载特性,当图片在不可见区域时不加载图片,当图片即将可见时才开始加载图片", "IntersectionObserverTitle": "懒加载" }, + "BootstrapBlazor.Server.Components.Samples.InputCurrency": { + "InputCurrencyCultureIntro": "自行指定文化信息,例如:en-US、zh-CN", + "InputCurrencyCultureIntro2": "指定巴西葡萄牙语,例如:pt-BR", + "InputCurrencyCultureTitle": "指文化用法", + "InputCurrencyCustomizeIntro": "使用 Formatter 回调来实现自定义格式用法", + "InputCurrencyCustomizeTitle": "自定义格式用法", + "InputCurrencyDescription": "仅允许输入标准的数字值,并提供格式化功能,例如:输入12345.78,千分位显示:12,345.78;货币显示:¥12,345.78;百分比显示:12,345.78%", + "InputCurrencyMemo": "本组件,实际上是 InputNumbers 组件的扩展,以满足有需要在 UI 中显示货币、百分比等格式化数值的需求", + "InputCurrencyNormalIntro": "Number 数值类型显示文本框,移动端自动弹出数字键盘", + "InputCurrencyNormalTitle": "基础用法", + "InputCurrencyPercentIntro": "可使用 FormatterFormatString 来实现百分比显示", + "InputCurrencyPercentTitle": "百分比用法", + "InputCurrencyRangeIntro": "设置 Max Min 来控制数值区间范围 1-10", + "InputCurrencyRangeTitle": "区间限制用法", + "InputCurrencyTitle": "InputCurrency 组件" + }, "BootstrapBlazor.Server.Components.Samples.InputGroups": { "InputGroupsCheckboxIntro": "往 InputGroup 里面增加 Checkbox 或者 CheckboxList", "InputGroupsCheckboxTitle": "复选框组合", @@ -6067,7 +6083,6 @@ "Picture.Required": "上传文件不能为空" }, "BootstrapBlazor.Server.Components.Samples.ValidateForms": { - "AsyncValidationError": "用户名已存在", "AsyncValidationUserName": "用户名", "ChangeButtonText": "更改组件", "CustomValidationFormComment1": "增加邮箱验证规则", diff --git a/src/BootstrapBlazor.Server/docs.json b/src/BootstrapBlazor.Server/docs.json index 4dd9a807ec2..3d4a710849f 100644 --- a/src/BootstrapBlazor.Server/docs.json +++ b/src/BootstrapBlazor.Server/docs.json @@ -104,6 +104,7 @@ "iframe": "IFrames", "image-viewer": "ImageViewers", "input-number": "InputNumbers", + "input-currency": "InputCurrency", "input": "Inputs", "input-group": "InputGroups", "ip": "Ips", diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor new file mode 100644 index 00000000000..225677568fc --- /dev/null +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor @@ -0,0 +1,17 @@ +@namespace BootstrapBlazor.Components +@typeparam TValue +@inherits BootstrapInputNumberBase + +@if (IsShowLabel) +{ + +} +@RenderInput + +@code { + RenderFragment RenderInput => + @; +} diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs new file mode 100644 index 00000000000..1087ec8bb49 --- /dev/null +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs @@ -0,0 +1,333 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using Microsoft.Extensions.Localization; +using System.Globalization; +using System.Text; + +namespace BootstrapBlazor.Components; + +/// +/// BootstrapInputCurrency 组件 +/// BootstrapInputCurrency component +/// +public partial class BootstrapInputCurrency +{ + /// + /// 获得/设置 是否为只读,默认为 false + /// Gets or sets whether readonly. Default is false + /// + [Parameter] + public bool Readonly { get; set; } + + /// + /// 获得/设置 最小值 + /// Gets or sets Minimum Value + /// + [Parameter] + public string? Min { get; set; } + + /// + /// 获得/设置 最大值 + /// Gets or sets Maximum Value + /// + [Parameter] + public string? Max { get; set; } + + /// + /// 获得/设置 数值解析及格式字符串使用的文化信息 + /// Gets or sets the culture used to parse and format numeric values + /// + [Parameter] + public CultureInfo CultureInfo { get; set; } = CultureInfo.InvariantCulture; + + /// + /// 获得/设置 自定义数值解析回调方法。解析成功后由组件格式化显示值 + /// Gets or sets the custom numeric parser. The component formats the value after successful parsing + /// + [Parameter] + public Func? Parser { get; set; } + + + [Inject] + [NotNull] + private IStringLocalizer>? Localizer { get; set; } + + private string? ReadonlyString => Readonly ? "true" : null; + + /// + /// 获得 文本框样式 + /// Get Text Box Style + /// + protected string? InputClassString => CssBuilder.Default("form-control") + .AddClass(CssClass).AddClass(ValidCss) + .AddClass($"border-{Color.ToDescriptionString()}", Color != Color.None) + .AddClassFromAttributes(AdditionalAttributes) + .Build(); + + private string? InputModeString => IsDecimalType() ? "decimal" : "numeric"; + + + private string? _lastInputValueString; + + private bool _manualInput; + + /// + /// + /// + protected override void OnInitialized() + { + base.OnInitialized(); + if (UseInputEvent) + { + _lastInputValueString ??= Value?.ToString(); + } + } + + /// + /// + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + + ParsingErrorMessage ??= Localizer[nameof(ParsingErrorMessage)]; + + if (Value is null) + { + _lastInputValueString = ""; + } + + if (UseInputEvent && !_manualInput) + { + _lastInputValueString = GetFormatString(Value); + } + } + + /// + /// + /// + /// + protected override void OnAfterRender(bool firstRender) + { + base.OnAfterRender(firstRender); + + if (_manualInput) + { + _manualInput = false; + } + } + + /// + /// + /// + protected override string? FormatParsingErrorMessage() => string.Format(CultureInfo.InvariantCulture, ParsingErrorMessage, DisplayText); + + /// + /// + /// + protected override string? FormatValueAsString(TValue? value) => UseInputEvent ? _lastInputValueString : GetFormatString(value); + + private string? GetFormatString(TValue? value) => Formatter != null + ? Formatter.Invoke(value) + : (!string.IsNullOrEmpty(FormatString) && value is IFormattable formattable + ? formattable.ToString(FormatString, CultureInfo) + : InternalFormat(value)); + + /// + /// InternalFormat 方法 + /// InternalFormat Method + /// + /// + /// + protected virtual string? InternalFormat(TValue? value) => value switch + { + null => null, + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => throw new InvalidOperationException($"Unsupported type {value!.GetType()}") + }; + + private TValue ParseValue(string value) + { + return value.TryConvertTo(CultureInfo, out var ret) + ? ret + : throw new InvalidOperationException($"Unsupported type {typeof(TValue)}"); + } + + private bool IsDecimalType() + { + // 检查是否允许带小数点数据类型 + var type = ValueType; + return type == typeof(float) || type == typeof(double) || type == typeof(decimal); + } + + /// + /// + /// + protected override async Task OnBlur() + { + if (!PreviousParsingAttemptFailed) + { + CurrentValue = SetMax(SetMin(Value)); + } + else + { + CurrentValue = default!; + } + + if (IsNullable() && string.IsNullOrEmpty(CurrentValueAsString)) + { + // set component value empty + await InvokeVoidAsync("clear", Id); + } + + if (OnBlurAsync != null) + { + await OnBlurAsync(Value); + } + } + + private TValue? SetMin(TValue? val) + { + if (!string.IsNullOrEmpty(Min) && val != null) + { + var min = ParseValue(Min); + if (Comparer.Default.Compare(val, min) < 0) + { + val = min; + } + } + return val; + } + + private TValue? SetMax(TValue? val) + { + if (!string.IsNullOrEmpty(Max) && val != null) + { + var max = ParseValue(Max); + if (Comparer.Default.Compare(val, max) > 0) + { + val = max; + } + } + return val; + } + + private string NormalizeValue(string value) + { + return Formatter != null || !string.IsNullOrEmpty(FormatString) + ? NormalizeNumericValue(value) + : value; + } + + private string NormalizeNumericValue(string value) + { + var format = CultureInfo.NumberFormat; + var decimalSeparators = new[] { format.NumberDecimalSeparator, format.CurrencyDecimalSeparator } + .Where(s => !string.IsNullOrEmpty(s)) + .Distinct() + .ToArray(); + var tokens = new[] + { + format.CurrencySymbol, + format.NumberGroupSeparator, + format.CurrencyGroupSeparator, + format.NegativeSign, + format.PositiveSign + } + .Where(s => !string.IsNullOrEmpty(s)) + .Distinct() + .OrderByDescending(s => s.Length) + .ToArray(); + + var builder = new StringBuilder(value.Length); + var hasDecimalSeparator = false; + for (var index = 0; index < value.Length;) + { + var decimalSeparator = Array.Find(decimalSeparators, separator => value.AsSpan(index).StartsWith(separator, StringComparison.Ordinal)); + if (decimalSeparator != null) + { + if (!hasDecimalSeparator) + { + builder.Append(decimalSeparator); + hasDecimalSeparator = true; + } + index += decimalSeparator.Length; + continue; + } + + var token = Array.Find(tokens, candidate => value.AsSpan(index).StartsWith(candidate, StringComparison.Ordinal)); + if (token != null) + { + builder.Append(token); + index += token.Length; + continue; + } + + var character = value[index]; + if (char.IsDigit(character) || char.IsWhiteSpace(character) || character is '(' or ')') + { + builder.Append(character); + } + else if (IsDecimalType() && character is 'e' or 'E' + && index > 0 + && index + 1 < value.Length + && char.IsDigit(value[index - 1]) + && (char.IsDigit(value[index + 1]) || value[index + 1] is '+' or '-')) + { + builder.Append(character); + } + index++; + } + return builder.ToString(); + } + + /// + /// + /// + /// + /// + /// + protected override bool TryParseValueFromString(string value, [MaybeNullWhen(false)] out TValue result, out string? validationErrorMessage) + { + bool ret; + if (string.IsNullOrEmpty(value)) + { + result = default; + validationErrorMessage = null; + + // nullable data type do not run here + _lastInputValueString = result!.ToString(); + ret = true; + } + else + { + var normalizedValue = NormalizeValue(value); + if (Parser != null) + { + var parsedValue = Parser(normalizedValue, CultureInfo); + ret = parsedValue.Success; + result = ret ? parsedValue.Value! : default; + } + else + { + ret = normalizedValue.TryConvertTo(CultureInfo, out result); + } + + validationErrorMessage = ret ? null : FormatParsingErrorMessage(); + + if (ret && UseInputEvent) + { + _lastInputValueString = value; + } + } + + if (UseInputEvent) + { + _manualInput = true; + } + return ret; + } +} diff --git a/src/BootstrapBlazor/Extensions/ObjectExtensions.cs b/src/BootstrapBlazor/Extensions/ObjectExtensions.cs index 4a9eecf76cc..380cc8ca98c 100644 --- a/src/BootstrapBlazor/Extensions/ObjectExtensions.cs +++ b/src/BootstrapBlazor/Extensions/ObjectExtensions.cs @@ -206,6 +206,62 @@ public static bool TryConvertTo(this string? source, [MaybeNullWhen(fals return ret; } + /// + /// 使用指定文化尝试将字符串表示的值转换为指定类型 + /// Tries to convert the string representation of a value to a specified type using the specified culture + /// + /// + /// + /// + /// + public static bool TryConvertTo(this string? source, CultureInfo culture, [MaybeNullWhen(false)] out TValue val) + { + var type = Nullable.GetUnderlyingType(typeof(TValue)) ?? typeof(TValue); + if (type == typeof(string)) + { + val = (TValue)(object)source!; + return true; + } + + if (source == null) + { + val = default!; + return true; + } + + if (source.Length == 0 || !type.IsNumber()) + { + var value = type == typeof(bool) + ? (object)source.Equals("true", StringComparison.CurrentCultureIgnoreCase) + : source; + return BindConverter.TryConvertTo(value, culture, out val); + } + + object? converted = Type.GetTypeCode(type) switch + { + TypeCode.SByte => sbyte.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.Byte => byte.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.Int16 => short.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.UInt16 => ushort.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.Int32 => int.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.UInt32 => uint.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.Int64 => long.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.UInt64 => ulong.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.Single => float.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.Double => double.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + _ => decimal.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null + }; + + if (converted != null) + { + val = (TValue)converted; + return true; + } + + val = default; + return false; + } + /// /// 将文件大小格式化为带有适当单位的字符串 /// Formats the file size into a string with appropriate units diff --git a/src/BootstrapBlazor/Locales/en.json b/src/BootstrapBlazor/Locales/en.json index a20e0ca9a8c..bba7c671b16 100644 --- a/src/BootstrapBlazor/Locales/en.json +++ b/src/BootstrapBlazor/Locales/en.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "The {0} field must be a number." }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "The {0} field must be a number." + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "Yes", "ButtonNoText": "No", diff --git a/src/BootstrapBlazor/Locales/zh.json b/src/BootstrapBlazor/Locales/zh.json index 3690d2b9253..8b70a11853e 100644 --- a/src/BootstrapBlazor/Locales/zh.json +++ b/src/BootstrapBlazor/Locales/zh.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "{0}字段值必须为 Number 类型" }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "{0}字段值必须为 Number 类型" + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "确认", "ButtonNoText": "取消", diff --git a/test/UnitTest/Components/InputCurrencyTest.cs b/test/UnitTest/Components/InputCurrencyTest.cs new file mode 100644 index 00000000000..c222a04c1fb --- /dev/null +++ b/test/UnitTest/Components/InputCurrencyTest.cs @@ -0,0 +1,491 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Reflection; + +namespace UnitTest.Components; + +public class InputCurrencyTest : BootstrapBlazorTestBase +{ + [Theory] + [InlineData(null)] + [InlineData(0.0)] + public async Task OnInput_Ok(double? v) + { + double? value = 0.0; + var cut = Context.Render>(builder => + { + builder.Add(a => a.Value, v); + builder.Add(a => a.UseInputEvent, true); + builder.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => + { + value = v; + })); + }); + var input = cut.Find("input"); + await cut.InvokeAsync(() => + { + input.Input("0.0"); + }); + cut.Contains("value=\"0.0\""); + + await cut.InvokeAsync(() => + { + input.Input("0.01"); + }); + cut.Contains("value=\"0.01\""); + } + + [Fact] + public void RenderAttributes_Ok() + { + var integer = Context.Render>(pb => + { + pb.Add(a => a.Readonly, true); + pb.Add(a => a.Color, Color.Danger); + pb.Add(a => a.AdditionalAttributes, new Dictionary + { + ["data-test"] = "currency" + }); + }); + var input = integer.Find("input"); + Assert.Equal("true", input.GetAttribute("readonly")); + Assert.Equal("numeric", input.GetAttribute("inputmode")); + Assert.Equal("currency", input.GetAttribute("data-test")); + Assert.Contains("border-danger", input.ClassList); + + var decimalInput = Context.Render>(); + Assert.Equal("decimal", decimalInput.Find("input").GetAttribute("inputmode")); + Assert.Null(decimalInput.Find("input").GetAttribute("readonly")); + } + + [Fact] + public void OnBlur_Ok() + { + var cut = Context.Render>(pb => + { + pb.Add(a => a.Min, "0"); + pb.Add(a => a.Max, "10"); + }); + cut.Contains("min=\"0\""); + cut.Contains("max=\"10\""); + + var input = cut.Find("input"); + cut.InvokeAsync(() => input.Blur()); + } + + [Fact] + public void ValidateForm() + { + var foo = new Cat() { Count = 20 }; + var cut = Context.Render(pb => + { + pb.Add(a => a.Model, foo); + pb.AddChildContent>(pb => + { + pb.Add(a => a.Value, foo.Count); + pb.Add(a => a.ValueExpression, Utility.GenerateValueExpression(foo, nameof(Cat.Count), typeof(int))); + }); + }); + cut.Contains("class=\"form-label\""); + + var input = cut.Find("input"); + cut.InvokeAsync(() => input.Change("")); + + var form = cut.Find("form"); + cut.InvokeAsync(() => form.Submit()); + cut.Contains("is-invalid"); + } + + [Fact] + public void InvalidOperationException_Error() + { + Assert.ThrowsAny(() => Context.Render>()); + } + + [Fact] + public void Formatter_Ok() + { + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, 10.01m); + pb.Add(a => a.Formatter, v => $"{v + 1}"); + }); + var input = cut.Find("input"); + Assert.Equal("11.01", input.GetAttribute("value")); + + cut.Render(pb => + { + pb.Add(a => a.Formatter, null); + pb.Add(a => a.FormatString, "#0.0"); + }); + Assert.Equal("10.0", input.GetAttribute("value")); + + input = cut.Find("input"); + cut.InvokeAsync(() => input.Change("")); + } + + [Fact] + public async Task CultureFormattingAndParsing_Ok() + { + var culture = CultureInfo.GetCultureInfo("de-DE"); + var value = 1234.5m; + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, value); + pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => value = v)); + pb.Add(a => a.CultureInfo, culture); + pb.Add(a => a.FormatString, "C2"); + }); + var input = cut.Find("input"); + Assert.Equal(value.ToString("C2", culture), input.GetAttribute("value")); + + await cut.InvokeAsync(() => input.Change("2.345,75 €")); + + Assert.Equal(2345.75m, value); + Assert.Equal(value.ToString("C2", culture), cut.Find("input").GetAttribute("value")); + } + + [Theory] + [InlineData("12345abc", 12345)] + [InlineData("-1,234.50 USD", -1234.50)] + [InlineData("1.2.3", 1.23)] + [InlineData("(1,234.50)", -1234.50)] + [InlineData("1e+3", 1000)] + [InlineData("1e-3", 0.001)] + [InlineData("1e", 1)] + [InlineData("e1", 1)] + public async Task FormattedInput_Normalizes_Ok(string source, double expected) + { + var value = 0d; + var culture = CultureInfo.GetCultureInfo("en-US"); + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, value); + pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => value = v)); + pb.Add(a => a.CultureInfo, culture); + pb.Add(a => a.FormatString, "0.##"); + }); + + await cut.InvokeAsync(() => cut.Find("input").Change(source)); + + Assert.Equal(expected, value); + Assert.Equal(expected.ToString("0.##", culture), cut.Find("input").GetAttribute("value")); + } + + [Fact] + public async Task FormattedInteger_Normalizes_Ok() + { + var value = 0; + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, value); + pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => value = v)); + pb.Add(a => a.FormatString, "N0"); + pb.Add(a => a.CultureInfo, CultureInfo.InvariantCulture); + }); + + await cut.InvokeAsync(() => cut.Find("input").Change("12abc")); + + Assert.Equal(12, value); + } + + [Fact] + public async Task Parser_SuccessFailureAndCulture_Ok() + { + var value = 1; + var culture = CultureInfo.GetCultureInfo("fr-FR"); + CultureInfo? receivedCulture = null; + var shouldSucceed = true; + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, value); + pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => value = v)); + pb.Add(a => a.CultureInfo, culture); + pb.Add(a => a.Parser, (text, currentCulture) => + { + receivedCulture = currentCulture; + return (shouldSucceed, shouldSucceed ? text.Length : default); + }); + }); + + await cut.InvokeAsync(() => cut.Find("input").Change("custom")); + Assert.Equal(6, value); + Assert.Same(culture, receivedCulture); + + shouldSucceed = false; + await cut.InvokeAsync(() => cut.Find("input").Change("123")); + Assert.Equal(6, value); + } + + [Fact] + public async Task UseInputEvent_FormatterPreservesInput_Ok() + { + var value = 0m; + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, value); + pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => value = v)); + pb.Add(a => a.UseInputEvent, true); + pb.Add(a => a.FormatString, "N2"); + pb.Add(a => a.CultureInfo, CultureInfo.GetCultureInfo("en-US")); + }); + + await cut.InvokeAsync(() => cut.Find("input").Input("$1,234.50abc")); + + Assert.Equal(1234.50m, value); + Assert.Equal("$1,234.50abc", cut.Find("input").GetAttribute("value")); + } + + [Fact] + public void Formatter_Null() + { + var cut = Context.Render>(pb => + { + pb.Add(a => a.FormatString, "d2"); + }); + cut.Contains("value=\"\""); + } + + [Fact] + public void Formatter_Error() + { + Assert.ThrowsAny(() => Context.Render()); + } + + [Fact] + public async Task Nullable_Ok() + { + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, 5); + }); + var input = cut.Find("input"); + await cut.InvokeAsync(() => + { + input.Change("1+2"); + input.Blur(); + }); + Assert.Null(cut.Instance.Value); + } + + [Fact] + public async Task OnBlurAsync_Ok() + { + var blur = false; + var cut = Context.Render>(builder => + { + builder.Add(a => a.OnBlurAsync, v => + { + blur = true; + return Task.CompletedTask; + }); + }); + var input = cut.Find("input"); + await cut.InvokeAsync(() => { input.Blur(); }); + Assert.True(blur); + } + + [Fact] + public async Task OnValueChanged_Ok() + { + int? changedValue = null; + var cut = Context.Render>(pb => + { + pb.Add(a => a.OnValueChanged, value => + { + changedValue = value; + return Task.CompletedTask; + }); + }); + + await cut.InvokeAsync(() => cut.Find("input").Change("12")); + + Assert.Equal(12, changedValue); + } + + [Fact] + public async Task MinMax_Ok() + { + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, 5); + pb.Add(a => a.Min, "10"); + }); + + var input = cut.Find("input"); + await cut.InvokeAsync(() => input.Blur()); + Assert.Equal(10, cut.Instance.Value); + + cut.Render(pb => + { + pb.Add(a => a.Value, 15); + pb.Add(a => a.Min, null); + pb.Add(a => a.Max, "10"); + }); + input = cut.Find("input"); + await cut.InvokeAsync(() => input.Blur()); + Assert.Equal(10, cut.Instance.Value); + + cut.Render(pb => + { + pb.Add(a => a.Value, 5); + pb.Add(a => a.Min, "0"); + pb.Add(a => a.Max, "10"); + }); + input = cut.Find("input"); + await cut.InvokeAsync(() => input.Blur()); + Assert.Equal(5, cut.Instance.Value); + } + + [Fact] + public async Task InvalidMin_Throws_Ok() + { + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, 1); + pb.Add(a => a.Min, "invalid"); + }); + + await Assert.ThrowsAsync(() => cut.InvokeAsync(() => cut.Find("input").Blur())); + } + + [Fact] + public void UnsignedType_Bind_Ok() + { + AssertInputNumberValueChanged(1, "2", 2); + AssertInputNumberValueChanged(1, "2", 2); + AssertInputNumberValueChanged(1, "2", 2); + AssertInputNumberValueChanged(1, "2", 2); + } + + [Fact] + public async Task Validate_Ok() + { + var model = new Foo() { Count = 1 }; + var cut = Context.Render(pb => + { + pb.Add(a => a.Model, model); + pb.AddChildContent>(builder => + { + builder.Add(a => a.Value, model.Count); + builder.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => + { + model.Count = v; + })); + builder.Add(a => a.ValueExpression, Utility.GenerateValueExpression(model, nameof(model.Count), typeof(int))); + }); + }); + var input = cut.Find(".form-control"); + + // 更改成非法数值 测试 CurrentValueAsString 赋值逻辑 + await cut.InvokeAsync(() => + { + input.Change("t"); + }); + Assert.Equal(1, model.Count); + + var valid = await cut.InvokeAsync(() => cut.Instance.ValidateAsync(CancellationToken.None)); + Assert.False(valid); + + await cut.InvokeAsync(() => + { + input.Change("t2"); + }); + Assert.Equal(1, model.Count); + valid = await cut.InvokeAsync(() => cut.Instance.ValidateAsync(CancellationToken.None)); + Assert.False(valid); + + await cut.InvokeAsync(() => + { + input.Change("2"); + }); + Assert.Equal(2, model.Count); + + valid = await cut.InvokeAsync(() => cut.Instance.ValidateAsync(CancellationToken.None)); + Assert.True(valid); + } + + [Fact] + public async Task TryParseValueFromString_Ok() + { + var model = new Foo() { Count = 1 }; + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, 1); + pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => + { + model.Count = v; + })); + pb.Add(a => a.ValueExpression, Utility.GenerateValueExpression(model, nameof(model.Count), typeof(int))); + }); + var input = cut.Find(".form-control"); + + // 更改成非法数值 测试 CurrentValueAsString 赋值逻辑 + await cut.InvokeAsync(() => + { + input.Change("t"); + }); + Assert.Equal(1, model.Count); + + await cut.InvokeAsync(() => + { + input.Change("t2"); + }); + Assert.Equal(1, model.Count); + + await cut.InvokeAsync(() => + { + input.Change("2"); + }); + Assert.Equal(2, model.Count); + } + + private class Cat + { + [Range(1, 10)] + public int Count { get; set; } + } + + private void AssertInputNumberValueChanged(TValue value, string inputValue, TValue expected) + { + var currentValue = value; + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, value); + pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => currentValue = v)); + }); + + var input = cut.Find(".form-control"); + cut.InvokeAsync(() => input.Change(inputValue)); + Assert.Equal(expected, currentValue); + } + + private class MockInputNumber : BootstrapInputCurrency + { + public override Task SetParametersAsync(ParameterView parameters) + { + parameters.SetParameterProperties(this); + + OnInitialized(); + + return Task.CompletedTask; + } + + protected override string? InternalFormat(string? value) + { + return base.InternalFormat(value); + } + + protected override void OnInitialized() + { + base.OnInitialized(); + + InternalFormat(""); + } + } +} diff --git a/test/UnitTest/Extensions/ObjectExtensionsTest.cs b/test/UnitTest/Extensions/ObjectExtensionsTest.cs index 387a135524a..e4ac33637e0 100644 --- a/test/UnitTest/Extensions/ObjectExtensionsTest.cs +++ b/test/UnitTest/Extensions/ObjectExtensionsTest.cs @@ -164,6 +164,124 @@ public static void TryConvertTo_Generic() Assert.True(result); } + [Fact] + public static void TryConvertTo_GenericCulture_StringNullAndEmpty() + { + var culture = CultureInfo.InvariantCulture; + + Assert.True("test".TryConvertTo(culture, out var text)); + Assert.Equal("test", text); + + Assert.True(((string?)null).TryConvertTo(culture, out var nullText)); + Assert.Null(nullText); + + Assert.True(((string?)null).TryConvertTo(culture, out var nullInteger)); + Assert.Equal(0, nullInteger); + + Assert.True(((string?)null).TryConvertTo(culture, out var nullNullableInteger)); + Assert.Null(nullNullableInteger); + + Assert.False(string.Empty.TryConvertTo(culture, out var emptyInteger)); + Assert.Equal(0, emptyInteger); + + Assert.True(string.Empty.TryConvertTo(culture, out var emptyNullableInteger)); + Assert.Null(emptyNullableInteger); + } + + [Theory] + [InlineData("true", true)] + [InlineData("TRUE", true)] + [InlineData("false", false)] + [InlineData("False", false)] + public static void TryConvertTo_GenericCulture_Boolean(string source, bool expected) + { + Assert.True(source.TryConvertTo(CultureInfo.InvariantCulture, out var actual)); + Assert.Equal(expected, actual); + } + + [Fact] + public static void TryConvertTo_GenericCulture_NumericBoundaries() + { + var culture = CultureInfo.InvariantCulture; + + AssertConversion("-128", culture, sbyte.MinValue); + AssertConversion("255", culture, byte.MaxValue); + AssertConversion("-32768", culture, short.MinValue); + AssertConversion("65535", culture, ushort.MaxValue); + AssertConversion("-2147483648", culture, int.MinValue); + AssertConversion("4294967295", culture, uint.MaxValue); + AssertConversion("-9223372036854775808", culture, long.MinValue); + AssertConversion("18446744073709551615", culture, ulong.MaxValue); + AssertConversion("-3.4028235E+38", culture, float.MinValue); + AssertConversion("1.7976931348623157E+308", culture, double.MaxValue); + AssertConversion("79228162514264337593543950335", culture, decimal.MaxValue); + } + + [Fact] + public static void TryConvertTo_GenericCulture_NumericFailuresReturnDefault() + { + var culture = CultureInfo.InvariantCulture; + + AssertConversionFails("-129", culture); + AssertConversionFails("256", culture); + AssertConversionFails("-32769", culture); + AssertConversionFails("65536", culture); + AssertConversionFails("-2147483649", culture); + AssertConversionFails("4294967296", culture); + AssertConversionFails("-9223372036854775809", culture); + AssertConversionFails("18446744073709551616", culture); + AssertConversionFails("not-a-number", culture); + AssertConversionFails("not-a-number", culture); + AssertConversionFails("79228162514264337593543950336", culture); + } + + [Fact] + public static void TryConvertTo_GenericCulture_NullableNumeric() + { + var culture = CultureInfo.GetCultureInfo("en-US"); + + AssertConversion("$ 2", culture, 2m); + AssertConversion("1,234", culture, 1234); + } + + [Fact] + public static void TryConvertTo_GenericCulture_NumberFormats() + { + var enUs = CultureInfo.GetCultureInfo("en-US"); + AssertConversion("$ 2", enUs, 2d); + AssertConversion("$1,234.50", enUs, 1234.50m); + + var deDe = CultureInfo.GetCultureInfo("de-DE"); + AssertConversion("1.234,5", deDe, 1234.5d); + + var frFr = CultureInfo.GetCultureInfo("fr-FR"); + AssertConversion(1234.5m.ToString("N2", frFr), frFr, 1234.5m); + } + + [Fact] + public static void TryConvertTo_GenericCulture_PreservesPrecision() + { + var culture = CultureInfo.GetCultureInfo("en-US"); + + AssertConversion("$12,345,678,901,234,567,890.123456789", culture, 12345678901234567890.123456789m); + AssertConversion("18,446,744,073,709,551,615", culture, ulong.MaxValue); + } + + [Fact] + public static void TryConvertTo_GenericCulture_NonNumericUsesBindConverter() + { + var culture = CultureInfo.InvariantCulture; + var guid = Guid.NewGuid(); + + AssertConversion(guid.ToString(), culture, guid); + + Assert.False("not-a-date".TryConvertTo(culture, out var invalidDate)); + Assert.Equal(default, invalidDate); + + var deDe = CultureInfo.GetCultureInfo("de-DE"); + AssertConversion("31.12.2025", deDe, new DateTime(2025, 12, 31)); + } + [Theory] [InlineData(100f, "100 B")] [InlineData(1024f, "1.0 KB")] @@ -367,6 +485,18 @@ public void CreateInstance_Ok() Assert.Null(bar.Bar); } + private static void AssertConversion(string source, CultureInfo culture, TValue expected) + { + Assert.True(source.TryConvertTo(culture, out var actual)); + Assert.Equal(expected, actual); + } + + private static void AssertConversionFails(string source, CultureInfo culture) + { + Assert.False(source.TryConvertTo(culture, out var actual)); + Assert.Equal(default, actual); + } + private interface MockInterface { string? Name { get; set; } From 1ec833f8c1af7d907ff9db8ce749825e973365fb Mon Sep 17 00:00:00 2001 From: Tony-ST0754 <6914529@qq.com> Date: Sun, 6 Sep 2026 19:00:05 +0800 Subject: [PATCH 6/6] =?UTF-8?q?refactor(InputCurrency):=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=B8=85=E6=B4=97=E6=A0=87=E5=87=86=E5=8C=96?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor:重构数据清洗标准化逻辑 test:调整单元测试 --- .../BootstrapInputCurrency.razor.cs | 7 ---- test/UnitTest/Components/InputCurrencyTest.cs | 34 ++++++++----------- 2 files changed, 14 insertions(+), 27 deletions(-) diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs index 1087ec8bb49..78fb94e6854 100644 --- a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs @@ -216,13 +216,6 @@ protected override async Task OnBlur() } private string NormalizeValue(string value) - { - return Formatter != null || !string.IsNullOrEmpty(FormatString) - ? NormalizeNumericValue(value) - : value; - } - - private string NormalizeNumericValue(string value) { var format = CultureInfo.NumberFormat; var decimalSeparators = new[] { format.NumberDecimalSeparator, format.CurrencyDecimalSeparator } diff --git a/test/UnitTest/Components/InputCurrencyTest.cs b/test/UnitTest/Components/InputCurrencyTest.cs index c222a04c1fb..5e8f7913faa 100644 --- a/test/UnitTest/Components/InputCurrencyTest.cs +++ b/test/UnitTest/Components/InputCurrencyTest.cs @@ -156,9 +156,11 @@ public async Task CultureFormattingAndParsing_Ok() [InlineData("1.2.3", 1.23)] [InlineData("(1,234.50)", -1234.50)] [InlineData("1e+3", 1000)] + [InlineData("1E3", 1000)] [InlineData("1e-3", 0.001)] [InlineData("1e", 1)] [InlineData("e1", 1)] + [InlineData("1ea", 1)] public async Task FormattedInput_Normalizes_Ok(string source, double expected) { var value = 0d; @@ -200,6 +202,7 @@ public async Task Parser_SuccessFailureAndCulture_Ok() var value = 1; var culture = CultureInfo.GetCultureInfo("fr-FR"); CultureInfo? receivedCulture = null; + string? receivedText = null; var shouldSucceed = true; var cut = Context.Render>(pb => { @@ -209,17 +212,20 @@ public async Task Parser_SuccessFailureAndCulture_Ok() pb.Add(a => a.Parser, (text, currentCulture) => { receivedCulture = currentCulture; + receivedText = text; return (shouldSucceed, shouldSucceed ? text.Length : default); }); }); - await cut.InvokeAsync(() => cut.Find("input").Change("custom")); - Assert.Equal(6, value); + await cut.InvokeAsync(() => cut.Find("input").Change("123custom")); + Assert.Equal(3, value); + Assert.Equal("123", receivedText); Assert.Same(culture, receivedCulture); shouldSucceed = false; - await cut.InvokeAsync(() => cut.Find("input").Change("123")); - Assert.Equal(6, value); + await cut.InvokeAsync(() => cut.Find("input").Change("456custom")); + Assert.Equal("456", receivedText); + Assert.Equal(3, value); } [Fact] @@ -392,18 +398,12 @@ await cut.InvokeAsync(() => var valid = await cut.InvokeAsync(() => cut.Instance.ValidateAsync(CancellationToken.None)); Assert.False(valid); - await cut.InvokeAsync(() => - { - input.Change("t2"); - }); + await cut.InvokeAsync(() => input.Change("test")); Assert.Equal(1, model.Count); valid = await cut.InvokeAsync(() => cut.Instance.ValidateAsync(CancellationToken.None)); Assert.False(valid); - await cut.InvokeAsync(() => - { - input.Change("2"); - }); + await cut.InvokeAsync(() => input.Change("t2")); Assert.Equal(2, model.Count); valid = await cut.InvokeAsync(() => cut.Instance.ValidateAsync(CancellationToken.None)); @@ -432,16 +432,10 @@ await cut.InvokeAsync(() => }); Assert.Equal(1, model.Count); - await cut.InvokeAsync(() => - { - input.Change("t2"); - }); + await cut.InvokeAsync(() => input.Change("test")); Assert.Equal(1, model.Count); - await cut.InvokeAsync(() => - { - input.Change("2"); - }); + await cut.InvokeAsync(() => input.Change("t2")); Assert.Equal(2, model.Count); }