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
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone

using Microsoft.Extensions.Localization;
using System.Globalization;

namespace BootstrapBlazor.Components;

Expand Down Expand Up @@ -569,10 +568,9 @@ private async Task OnClear()
/// <param name="validationErrorMessage"></param>
protected override bool TryParseValueFromString(string value, [MaybeNullWhen(false)] out TValue result, out string? validationErrorMessage)
{
var format = ViewMode == DatePickerViewMode.DateTime ? DateTimeFormat : DateFormat;
result = default;
validationErrorMessage = null;
var ret = DateTime.TryParseExact(value, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var val);
var ret = DateTimeHelper.TryToDateTime(value, out var val);
if (ret)
{
result = (TValue)(object)val;
Expand Down
87 changes: 87 additions & 0 deletions src/BootstrapBlazor/Utils/DateTimeHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// 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.Components;

static class DateTimeHelper
{
/// <summary>
/// 无分隔符等标准解析无法识别的紧凑格式,需要显式列出
/// </summary>
private static readonly string[] CompactFormats =
[
"yyyyMMdd",
"yyyyMMddHHmm",
"yyyyMMddHHmmss",
"yyyyMMddHHmmssfff",
"yyyyMMdd HHmmss",
"yyyyMMdd HH:mm:ss",
"yyyyMMdd HH:mm",
"yyyy-M-d",
"yyyy/M/d"
];

/// <summary>
/// 将字符串解析为 <see cref="DateTime"/>,无法解析时返回 <see langword="null"/>
/// </summary>
public static DateTime? ToDateTime(string value)
=> TryToDateTime(value, out var result) ? result : null;

/// <summary>
/// 将字符串解析为 <see cref="DateTime"/>,无法解析时返回 <paramref name="defaultValue"/>
/// </summary>
/// <param name="value">要解析的字符串</param>
/// <param name="defaultValue">解析失败时返回的默认值</param>
public static DateTime ToDateTime(string value, DateTime defaultValue)
=> TryToDateTime(value, out var result) ? result : defaultValue;

/// <summary>
/// 尝试将字符串解析为 <see cref="DateTime"/>
/// </summary>
/// <param name="value">要解析的字符串</param>
/// <param name="result">解析成功时的结果,失败时为 <see cref="DateTime.MinValue"/></param>
public static bool TryToDateTime(string value, out DateTime result)
{
result = DateTime.MinValue;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}

var text = value.Trim();

// 先尝试无分隔符等紧凑格式(如 20260501),这类格式标准解析无法识别
if (DateTime.TryParseExact(text, CompactFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out result))
{
return true;
}

// 再回退到通用解析,覆盖 2026-5-1、2026/5/1、2026-05-01 13:04:05 等带分隔符写法
return DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out result);
}

/// <summary>
/// 将字符串解析为 <see cref="DateTimeOffset"/>,无法解析时返回 <see langword="null"/>
/// </summary>
/// <remarks>
/// 适用于 <c>DateTimePicker</c> 绑定 <see cref="DateTimeOffset"/> 的场景;
/// 不含时区信息时按本地时区处理。
/// </remarks>
/// <param name="value">要解析的字符串</param>
/// <returns>解析成功返回对应的 <see cref="DateTimeOffset"/>,否则返回 <see langword="null"/></returns>
public static DateTimeOffset? ToDateTimeOffset(string value)
{
if (TryToDateTime(value, out var dateTime))
{
// 紧凑/无时区字符串解析出的 Kind 为 Unspecified,按本地时区补齐偏移
return dateTime.Kind == DateTimeKind.Unspecified
? new DateTimeOffset(DateTime.SpecifyKind(dateTime, DateTimeKind.Local))
: new DateTimeOffset(dateTime);
}
return null;
}
}
148 changes: 148 additions & 0 deletions test/UnitTest/Utils/DateTimeHelperTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// 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.Runtime.CompilerServices;

namespace UnitTest.Utils;

/// <summary>
/// <c>DateTimeHelper</c> 为内部类,通过 <see cref="UnsafeAccessorAttribute"/> 配合
/// <see cref="UnsafeAccessorTypeAttribute"/> 直接访问其静态方法进行测试
/// </summary>
public class DateTimeHelperTest
{
/// <summary>
/// 内部类型的程序集限定名
/// </summary>
private const string HelperTypeName = "BootstrapBlazor.Components.DateTimeHelper, BootstrapBlazor";

[Fact]
public void ToDateTime_Ok()
{
// 紧凑格式解析成功
Assert.Equal(new DateTime(2026, 5, 1), ToDateTime(null, "20260501"));

// 带分隔符格式解析成功
Assert.Equal(new DateTime(2026, 5, 1, 13, 4, 5), ToDateTime(null, "2026-05-01 13:04:05"));
}

[Fact]
public void ToDateTime_Null()
{
// 解析失败返回 null
Assert.Null(ToDateTime(null, "test"));
Assert.Null(ToDateTime(null, null!));
}

[Fact]
public void ToDateTime_DefaultValue_Ok()
{
var defaultValue = new DateTime(2020, 1, 1);

// 解析成功返回解析值
Assert.Equal(new DateTime(2026, 5, 1), ToDateTimeWithDefaultValue(null, "20260501", defaultValue));

// 解析失败返回默认值
Assert.Equal(defaultValue, ToDateTimeWithDefaultValue(null, "test", defaultValue));
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t")]
public void TryToDateTime_NullOrWhiteSpace(string? value)
{
// 空字符串直接返回 false 且结果为 DateTime.MinValue
Assert.False(TryToDateTime(null, value!, out var result));
Assert.Equal(DateTime.MinValue, result);
}

[Fact]
public void TryToDateTime_Invalid()
{
// 两种解析方式均失败
Assert.False(TryToDateTime(null, "test", out var result));
Assert.Equal(DateTime.MinValue, result);

Assert.False(TryToDateTime(null, "20261301", out _));
}

[Theory]
[InlineData("20260501", 2026, 5, 1, 0, 0, 0, 0)]
[InlineData("202605011304", 2026, 5, 1, 13, 4, 0, 0)]
[InlineData("20260501130405", 2026, 5, 1, 13, 4, 5, 0)]
[InlineData("20260501130405123", 2026, 5, 1, 13, 4, 5, 123)]
[InlineData("20260501 130405", 2026, 5, 1, 13, 4, 5, 0)]
[InlineData("20260501 13:04:05", 2026, 5, 1, 13, 4, 5, 0)]
[InlineData("20260501 13:04", 2026, 5, 1, 13, 4, 0, 0)]
[InlineData("2026-5-1", 2026, 5, 1, 0, 0, 0, 0)]
[InlineData("2026/5/1", 2026, 5, 1, 0, 0, 0, 0)]
public void TryToDateTime_CompactFormats(string value, int year, int month, int day, int hour, int minute, int second, int millisecond)
{
// 紧凑格式由 TryParseExact 分支解析
Assert.True(TryToDateTime(null, value, out var result));
Assert.Equal(new DateTime(year, month, day, hour, minute, second, millisecond), result);
}

[Theory]
[InlineData("2026-05-01")]
[InlineData("2026/05/01")]
public void TryToDateTime_Fallback(string value)
{
// 标准格式由 TryParse 回退分支解析
Assert.True(TryToDateTime(null, value, out var result));
Assert.Equal(new DateTime(2026, 5, 1), result);
}

[Fact]
public void TryToDateTime_Trim()
{
// 前后空白被裁剪后可正常解析
Assert.True(TryToDateTime(null, " 20260501 ", out var result));
Assert.Equal(new DateTime(2026, 5, 1), result);
}

[Fact]
public void ToDateTimeOffset_Unspecified()
{
// 无时区信息按本地时区补齐偏移
var value = new DateTime(2026, 5, 1, 13, 4, 5);
var expected = TimeZoneInfo.Local.GetUtcOffset(value);

var actual = ToDateTimeOffset(null, "20260501 130405");
Assert.NotNull(actual);
Assert.Equal(expected, actual.Value.Offset);
Assert.Equal(value, actual.Value.DateTime);
}

[Fact]
public void ToDateTimeOffset_Local()
{
// 带时区信息的字符串解析出的 Kind 不是 Unspecified,直接构造
var actual = ToDateTimeOffset(null, "2026-05-01T13:04:05Z");
Assert.NotNull(actual);
Assert.Equal(new DateTime(2026, 5, 1, 13, 4, 5, DateTimeKind.Utc), actual.Value.UtcDateTime);
}

[Fact]
public void ToDateTimeOffset_Null()
{
// 解析失败返回 null
Assert.Null(ToDateTimeOffset(null, "test"));
}

[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "ToDateTime")]
static extern DateTime? ToDateTime([UnsafeAccessorType(HelperTypeName)] object? @this, string value);

[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "ToDateTime")]
static extern DateTime ToDateTimeWithDefaultValue([UnsafeAccessorType(HelperTypeName)] object? @this, string value, DateTime defaultValue);

[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "TryToDateTime")]
static extern bool TryToDateTime([UnsafeAccessorType(HelperTypeName)] object? @this, string value, out DateTime result);

[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "ToDateTimeOffset")]
static extern DateTimeOffset? ToDateTimeOffset([UnsafeAccessorType(HelperTypeName)] object? @this, string value);
}
Loading