From 580eeb1c668232f61edd65f48c4b002ff1a488fa Mon Sep 17 00:00:00 2001 From: Denis Voituron Date: Sat, 19 Apr 2025 11:05:29 +0200 Subject: [PATCH 1/9] Add DateTimeProvider and Extension methods --- src/Core/Extensions/DateTimeExtensions.cs | 387 ++++++++++++++++++ .../Utilities/DateTime/DateTimeProvider.cs | 51 +++ .../DateTime/DateTimeProviderContext.cs | 93 +++++ src/Core/Utilities/DateTime/ReadMe.md | 131 ++++++ 4 files changed, 662 insertions(+) create mode 100644 src/Core/Extensions/DateTimeExtensions.cs create mode 100644 src/Core/Utilities/DateTime/DateTimeProvider.cs create mode 100644 src/Core/Utilities/DateTime/DateTimeProviderContext.cs create mode 100644 src/Core/Utilities/DateTime/ReadMe.md diff --git a/src/Core/Extensions/DateTimeExtensions.cs b/src/Core/Extensions/DateTimeExtensions.cs new file mode 100644 index 0000000000..00d65479e6 --- /dev/null +++ b/src/Core/Extensions/DateTimeExtensions.cs @@ -0,0 +1,387 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System.Globalization; + +namespace Microsoft.FluentUI.AspNetCore.Components.Extensions; + +/// +/// Extension methods for . +/// +public static class DateTimeExtensions +{ + /// + /// Returns a string in the ISO format yyyy-MM-dd. + /// + /// + /// + public static string ToIsoDateString(this DateTime? self) + { + if (self == null) + { + return string.Empty; + } + + return $"{self.Value.Year:D4}-{self.Value.Month:D2}-{self.Value.Day:D2}"; + } + + /// + /// Returns the first day of the month. + /// + /// + /// + /// + public static DateTime StartOfMonth(this DateTime self, CultureInfo culture) + { + var month = culture.Calendar.GetMonth(self); + var year = culture.Calendar.GetYear(self); + return culture.Calendar.ToDateTime(year, month, 1, 0, 0, 0, 0); + } + + /// + /// Returns the first day of the year + /// + /// + /// + /// + public static DateTime StartOfYear(this DateTime self, CultureInfo culture) + { + var year = culture.Calendar.GetYear(self); + return culture.Calendar.ToDateTime(year, 1, 1, 0, 0, 0, 0); + } + + /// + /// Returns the last day of the year. + /// + /// + /// + /// + public static DateTime EndOfYear(this DateTime self, CultureInfo culture) + { + return self.StartOfYear(culture).AddYears(1, culture).AddDays(-1, culture); + } + + /// + /// Returns the last day of the month. + /// + /// + /// + /// + public static DateTime EndOfMonth(this DateTime self, CultureInfo culture) + { + var month = culture.Calendar.GetMonth(self); + var year = culture.Calendar.GetYear(self); + var days = culture.Calendar.GetDaysInMonth(year, month); + return culture.Calendar.ToDateTime(year, month, days, 0, 0, 0, 0); + } + /// + /// Returns the first day of the week. + /// + /// + /// + /// + public static DateTime StartOfWeek(this DateTime self, DayOfWeek firstDayOfWeek) + { + var diff = (7 + (self.DayOfWeek - firstDayOfWeek)) % 7; + if (self.Year == 1 && self.Month == 1 && (self.Day - diff) < 1) + { + return self.Date; + } + + return self.AddDays(-1 * diff).Date; + } + + /// + /// Returns the first day of the week. + /// + /// + /// + /// + public static DateTime StartOfWeek(this DateTime self, CultureInfo culture) + { + var minDate = culture.Calendar.MinSupportedDateTime; + var firstDayOfWeek = culture.DateTimeFormat.FirstDayOfWeek; + + var diff = (7 + (self.DayOfWeek - firstDayOfWeek)) % 7; + if (self.Year == minDate.Year && self.Month == minDate.Month && (self.Day - diff) < 1) + { + return self.Date; + } + + return self.AddDays(-1 * diff, culture).Date; + } + + /// + /// Returns the first day of the week. + /// + /// + /// + public static DateTime StartOfWeek(this DateTime self) + { + return StartOfWeek(self, CultureInfo.CurrentUICulture); + } + + /* + /// + /// Get a string showing how long ago a DateTime was, for example '4 minutes ago'. + /// + /// + /// + /// + /// Inspired from https://github.com/NickStrupat/TimeAgo. + public static string ToTimeAgo(this TimeSpan delay, TimeAgoOptions? resources = null) + { + const int MAX_SECONDS_FOR_JUST_NOW = 10; + + if (resources == null) + { + resources = new TimeAgoOptions(); + } + + if (delay.Days > 365) + { + var years = Math.Round(decimal.Divide(delay.Days, 365)); + return string.Format(years == 1 ? resources.YearAgo : resources.YearsAgo, years); + } + + if (delay.Days > 30) + { + var months = delay.Days / 30; + if (delay.Days % 31 != 0) + { + months += 1; + } + + return string.Format(months == 1 ? resources.MonthAgo : resources.MonthsAgo, months); + } + + if (delay.Days > 0) + { + return string.Format(delay.Days == 1 ? resources.DayAgo : resources.DaysAgo, delay.Days); + } + + if (delay.Hours > 0) + { + return string.Format(delay.Hours == 1 ? resources.HourAgo : resources.HoursAgo, delay.Hours); + } + + if (delay.Minutes > 0) + { + return string.Format(delay.Minutes == 1 ? resources.MinuteAgo : resources.MinutesAgo, delay.Minutes); + } + + if (delay.Seconds > MAX_SECONDS_FOR_JUST_NOW) + { + return string.Format(resources.SecondsAgo, delay.Seconds); + } + + if (delay.Seconds <= MAX_SECONDS_FOR_JUST_NOW) + { + return string.Format(resources.SecondAgo, delay.Seconds); + } + + throw new NotSupportedException("The DateTime object does not have a supported value."); + } + */ + + /// + /// Converts the DateOnly to an equivalent DateTime. + /// + /// + /// + public static DateTime ToDateTime(this DateOnly value) + { + return value.ToDateTime(TimeOnly.MinValue); + } + + /// + /// Converts the TimeOnly to an equivalent DateTime. + /// + /// + /// + public static DateTime ToDateTime(this TimeOnly value) + { + return new DateTime(value.Ticks); + } + + /// + /// Converts the nullable DateOnly to an equivalent DateTime. + /// Returns if the is null. + /// + /// + /// + public static DateTime ToDateTime(this DateOnly? value) + { + return value == null ? DateTime.MinValue : value.Value.ToDateTime(TimeOnly.MinValue); + } + + /// + /// Converts the nullable TimeOnly to an equivalent DateTime. + /// Returns if the is null. + /// + /// + /// + public static DateTime ToDateTime(this TimeOnly? value) + { + return value == null ? DateTime.MinValue : value.Value.ToDateTime(); + } + + /// + /// Converts the nullable DateOnly to an equivalent DateTime. + /// + /// + /// + public static DateTime? ToDateTimeNullable(this DateOnly? value) + { + return value == null ? (DateTime?)null : value.Value.ToDateTime(TimeOnly.MinValue); + } + + /// + /// Converts the nullable TimeOnly to an equivalent DateTime. + /// + /// + /// + public static DateTime? ToDateTimeNullable(this TimeOnly? value) + { + return value == null ? (DateTime?)null : value.Value.ToDateTime(); + } + + /// + /// Converts the nullable DateTime to an equivalent DateTime. + /// Returns if the is null. + /// + /// + /// + public static DateTime ToDateTime(this DateTime? value) + { + return value == null ? DateTime.MinValue : value.Value; + } + + /// + /// Converts the nullable DateTime to an equivalent DateOnly, removing the time part. + /// Returns if the is null. + /// + /// + /// + public static DateOnly ToDateOnly(this DateTime? value) + { + return value == null ? DateOnly.MinValue : DateOnly.FromDateTime(value.Value); + } + + /// + /// Converts the nullable DateTime to an equivalent TimeOnly, removing the time part. + /// Returns if the is null. + /// + /// + /// + public static TimeOnly ToTimeOnly(this DateTime? value) + { + return value == null ? TimeOnly.MinValue : TimeOnly.FromDateTime(value.Value); + } + + /// + /// Converts the nullable DateTime to an equivalent DateOnly?, removing the time part. + /// + /// + /// + public static DateOnly? ToDateOnlyNullable(this DateTime? value) + { + return value == null ? (DateOnly?)null : DateOnly.FromDateTime(value.Value); + } + + /// + /// Converts the nullable DateTime to an equivalent TimeOnly?, removing the time part. + /// + /// + /// + public static TimeOnly? ToTimeOnlyNullable(this DateTime? value) + { + return value == null ? (TimeOnly?)null : TimeOnly.FromDateTime(value.Value); + } + + /// + /// Returns the year + /// + /// + /// + /// + public static int GetYear(this DateTime value, CultureInfo culture) + { + return culture.Calendar.GetYear(value); + } + + /// + /// Returns the month + /// + /// + /// + /// + public static int GetMonth(this DateTime value, CultureInfo culture) + { + return culture.Calendar.GetMonth(value); + } + + /// + /// Returns the day + /// + /// + /// + /// + public static int GetDay(this DateTime value, CultureInfo culture) + { + return culture.Calendar.GetDayOfMonth(value); + } + + /// + /// Returns the DateTime resulting from adding the given number of + /// days to the specified DateTime. + /// + /// + /// + /// + /// + public static DateTime AddDays(this DateTime value, int days, CultureInfo culture) + { + return culture.Calendar.AddDays(value, days); + } + + /// + /// Returns the DateTime resulting from adding the given number of + /// months to the specified DateTime. + /// + /// + /// + /// + /// + public static DateTime AddMonths(this DateTime value, int months, CultureInfo culture) + { + return culture.Calendar.AddMonths(value, months); + } + + /// + /// Returns the DateTime resulting from adding the given number of + /// years to the specified DateTime. + /// + /// + /// + /// + /// + public static DateTime AddYears(this DateTime value, int years, CultureInfo culture) + { + return culture.Calendar.AddYears(value, years); + } + + /// + /// Returns the name of the month + /// + /// + /// + /// + public static string GetMonthName(this DateTime value, CultureInfo culture) + { + var month = culture.Calendar.GetMonth(value); + + return culture.DateTimeFormat.MonthNames[month - 1]; + } +} diff --git a/src/Core/Utilities/DateTime/DateTimeProvider.cs b/src/Core/Utilities/DateTime/DateTimeProvider.cs new file mode 100644 index 0000000000..6781188fea --- /dev/null +++ b/src/Core/Utilities/DateTime/DateTimeProvider.cs @@ -0,0 +1,51 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Returns the current date and time on this computer, expressed as the local time. +/// +public static class DateTimeProvider +{ + /// + /// Gets a object that is set to the current date and time + /// on this computer, expressed as the local time. + /// + public static DateTime Now => DateTimeProviderContext.Current == null + ? GetSystemDate() + : DateTimeProviderContext.Current.NextValue(); + + /// + /// Gets a object that is set to the current date and time + /// on this computer, expressed as the Coordinated Universal Time (UTC). + /// + public static DateTime UtcNow => Now.ToUniversalTime(); + + /// + /// Gets a object that is set to today's date, with the time component set to 00:00:00. + /// + public static DateTime Today => Now.Date; + + /// + /// Indicates whether a context is required to be active. + /// + public static bool RequiredActiveContext { get; set; } + + /// + /// Returns the current date and time on this computer. + /// + /// Indicates whether a context is required to be active (used by internal unit tests). + /// The current date and time on this computer. + /// If is true and no context is active. + internal static DateTime GetSystemDate(bool requiredContext = true) + { + if (RequiredActiveContext && requiredContext) + { + throw new InvalidOperationException("DateTimeProvider requires a context to be set (e.g. `using var context = new DateTimeProviderContext(new DateTime(2025, 1, 18));`"); + } + + return DateTime.Now; + } +} diff --git a/src/Core/Utilities/DateTime/DateTimeProviderContext.cs b/src/Core/Utilities/DateTime/DateTimeProviderContext.cs new file mode 100644 index 0000000000..fd1143e411 --- /dev/null +++ b/src/Core/Utilities/DateTime/DateTimeProviderContext.cs @@ -0,0 +1,93 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System.Collections.Immutable; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Represents the context for the . +/// This context returns the specified date and time while it is in scope. +/// +public record DateTimeProviderContext : IDisposable +{ + private static readonly AsyncLocal> s_asyncScopeStack = new(); + private readonly AsyncLocal _asyncCurrentIndex = new(); + + /// + /// Gets the current . + /// + internal static DateTimeProviderContext? Current => s_asyncScopeStack.Value?.IsEmpty == false ? s_asyncScopeStack.Value.Peek() : null; + + /// + /// Create a new context for the using a sequence of date and time. + /// + /// Sequence of date and time to return while in scope. + public DateTimeProviderContext(Func sequence) + { + s_asyncScopeStack.Value = (s_asyncScopeStack.Value ?? ImmutableStack.Empty).Push(this); + Sequence = sequence; + } + + /// + /// Create a new context for the using the specified date and time. + /// + /// Specifies the date and time to return while in scope. + public DateTimeProviderContext(DateTime value) : this(_ => value) { } + + /// + /// Create a new context for the using a list of date and time. + /// Each call to will return the next date and time in the list, + /// until the last date and time is reached. + /// If more calls are made after the last date and time, an is thrown. + /// + /// + /// + public DateTimeProviderContext(DateTime[] values) + : this(i => i < values.Length + ? values[i] + : throw new InvalidOperationException("This is the last call in the sequence. No more dates are available.")) + { } + + /// + /// Gets the date and time to return while in scope. + /// + internal Func Sequence { get; } + + /// + /// Returns the next number between 0 and 99999999. + /// + /// + internal DateTime NextValue() + { + var currentIndex = _asyncCurrentIndex.Value; + var value = Sequence.Invoke(currentIndex); + + _asyncCurrentIndex.Value = currentIndex >= uint.MaxValue ? 0 : currentIndex + 1; + + return value; + } + + /// + /// Force the next value to used with the NextValue method. + /// Only for testing purposes. + /// + /// + internal void ForceNextValue(uint value) + { + _asyncCurrentIndex.Value = value; + } + + /// + /// Disposes the + /// and return to the previous context. + /// + public void Dispose() + { + if (s_asyncScopeStack.Value?.IsEmpty == false) + { + s_asyncScopeStack.Value = s_asyncScopeStack.Value.Pop(); + } + } +} diff --git a/src/Core/Utilities/DateTime/ReadMe.md b/src/Core/Utilities/DateTime/ReadMe.md new file mode 100644 index 0000000000..becac4e7f1 --- /dev/null +++ b/src/Core/Utilities/DateTime/ReadMe.md @@ -0,0 +1,131 @@ +# DateTimeProvider + +## Introduction + +Today, a developer came to see me to ask how to test his code that contains a reference to `DateTime.Now`. +This is because your application sometimes processes its data differently, depending on the current date. + +For example, how do you check the following code, which depends on the current quarter? + +```csharp +int quarter = (DateTime.Today.Month - 1) / 3 + 1; +if (quarter <= 2) + ... +else + ... +``` + +The main problem is that the date obviously changes every day. And the quarterly calculation will run +unit tests today, but maybe not tomorrow. + +## Dependency injection + +A clean way to do this, if you're using **dependency injection** (IoC) in your project, +is to create an interface to inject wherever you want to get the system's current date, +and define it, as required, in the unit tests. + +```csharp +public interface IDateTimeHelper +{ + DateTime GetDateTimeNow(); +} + +public class DateTimeHelper : IDateTimeHelper +{ + public DateTime GetDateTimeNow() + { + return DateTime.Now; + } +} +``` + +This works fine, as long as you use dependency injection. +But some people don't like injecting such a simple class. +Plus, what if you have existing code and just want to rewrite it to replace the date and time? + +## Ambient Context Model + +To avoid injecting such a simple class and to simplify updating existing code, +We propose a solution that uses the **Ambient Context Model**. + +To do this, use a `DateTimeProvider` class that determines the current context of use: +`DateTime.Now` is replaced by `DateTimeProvider.Now` in your code. + +```csharp +int trimester = (DateTimeProvider.Today.Month - 1) / 3 + 1; +``` + +This **provider** returns the system's current date. +However, by using it in a unit test, we can adapt the context to specify a predefined date. + +## Unit Test - Simulate a date + +```csharp +var result = DateTimeProvider.Now; // Returns DateTime.Now + +var fakeDate = new DateTime(2018, 5, 26); +using (var context = new DateTimeProviderContext(fakeDate)) +{ + var result = DateTimeProvider.Now; // Returns 2018-05-26 +} +``` + +As you can see from the code above, the only thing we need to do to simulate the system's current date is to wrap +our method call in a using block. This creates a new instance of **DateTimeProviderContext** and specifies +the **desired date** as an argument to the constructor. That's it! + +## Unit Test - Sequential dates + +Some uses require dates that evolve with each call. For example, to simulate a kind of `StopWatch`. +You can define a function (Lambda) that returns a different date for each call. + +For example, using the call index as the starting element : + +```csharp +using var contextSequence = new DateTimeProviderContext(i => i switch +{ + 0 => new DateTime(2018, 5, 26), + 1 => new DateTime(2019, 5, 27), + _ => DateTime.MinValue, +}); +``` + +However, if you already know the list of dates to be returned for each call, you can define them in a list : + +```csharp +using var contextSequence = new DateTimeProviderContext( +[ + new DateTime(2018, 5, 26), + new DateTime(2019, 5, 27) +]); +``` + +If you make more than 2 calls, an `InvalidOperationException` exception will be thrown. +This indicates that you have exhausted the defined return sequence and that there is no return value for the next call. + +## Unit Test - Requirement to use context + +Some uses (like Unit Tests) require that the date be defined in a context. +You can define this requirement setting the `DateTimeProvider.RequiredActiveContext` property to `true`. + +## Bechmarks + +These benchmarks results check the performance of `DateTimeProvider` against `DateTime.Now`. +- **SystemDateTime**: `DateTime.Now` +- **DateTimeProvider**: `DateTimeProvider.Now` + +These results show that `DateTimeProvider` performs just as well as `DateTime.Now`. + +``` +BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.3321) +11th Gen Intel Core i7-11850H 2.50GHz, 1 CPU, 16 logical and 8 physical cores``` +.NET SDK 9.0.200-preview.0.25057.12 + [Host] : .NET 9.0.2 (9.0.225.6610), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + DefaultJob : .NET 9.0.2 (9.0.225.6610), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + + +| Method | Mean | Error | StdDev | Median | +|--------------------- |---------:|---------:|---------:|---------:| +| SystemDateTime | 69.06 ns | 1.774 ns | 5.174 ns | 68.09 ns | +| DateTimeProvider | 68.78 ns | 1.896 ns | 5.561 ns | 67.79 ns | +``` From cbab4900231972060daf10fd0dbc8a71355ec401 Mon Sep 17 00:00:00 2001 From: Denis Voituron Date: Sat, 19 Apr 2025 11:23:25 +0200 Subject: [PATCH 2/9] Add Localization readme --- .../Layout/FluentLayoutHamburger.razor.cs | 3 +- src/Core/Localization/ReadMe.md | 38 +++++++++++++++++++ ...soft.FluentUI.AspNetCore.Components.csproj | 2 +- 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 src/Core/Localization/ReadMe.md diff --git a/src/Core/Components/Layout/FluentLayoutHamburger.razor.cs b/src/Core/Components/Layout/FluentLayoutHamburger.razor.cs index 1963eb1b0d..1025cd8374 100644 --- a/src/Core/Components/Layout/FluentLayoutHamburger.razor.cs +++ b/src/Core/Components/Layout/FluentLayoutHamburger.razor.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; +using Microsoft.FluentUI.AspNetCore.Components.Localization; namespace Microsoft.FluentUI.AspNetCore.Components; @@ -78,7 +79,7 @@ public partial class FluentLayoutHamburger /// protected override void OnInitialized() { - Title = Localizer["FluentLayoutHamburger_Title"]; + Title = Localizer[Localization.LanguageResource.FluentLayoutHamburger_Title]; var layout = Layout ?? LayoutContainer; layout?.AddHamburger(this); diff --git a/src/Core/Localization/ReadMe.md b/src/Core/Localization/ReadMe.md new file mode 100644 index 0000000000..e5fbf0e60b --- /dev/null +++ b/src/Core/Localization/ReadMe.md @@ -0,0 +1,38 @@ +# Localization + +Localization allows the text in some components to be translated. + +## Explanation + +**Fluent UI Blazor** itself provides English language strings for texts found in. +To customize translations in **Fluent UI Blazor**, the developer can register a +custom `IFluentLocalizer` implementation as a service, and register this custom localizer +in the `Program.cs` file, during the service registration. + +```csharp +builder.Services.AddFluentUIComponents(config => config.Localizer = new CustomFluentLocalizer()); +``` + +## How to add a new resource string? + +In this library, to add a new resource string, follow these steps: + +1. Open the file **LanguageResource.resx** and add a new entry with the name `_`. + Where `` is the name of the component, and `` is the name of the string. + Example: + - `MessageBox_ButtonYes` for the Yes button label, of the MessageBox component. + - `FluentLayoutHamburger_Title` for the title of the Layout Hamburger component. + +2. The `FluentComponentBase` class provides a `Localizer` property that can be used to access the + resource strings. The `Localizer` property is an instance of `IFluentLocalizer`, which + is registered in the service collection. + You can use the `Localizer` property to access the resource strings in your component. + The `Localization.LanguageResource` class contains the list of all resource string constants to use. + + Example: + ```csharp + protected override void OnInitialized() + { + Title = Localizer[Localization.LanguageResource.FluentLayoutHamburger_Title]; + } + ``` diff --git a/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj b/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj index 4a8587757f..cef5fa9602 100644 --- a/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj +++ b/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj @@ -110,7 +110,7 @@ - false + true true true CS1591 From 354b8fa3dbf45ac557a27c29193ffab2c3fa1623 Mon Sep 17 00:00:00 2001 From: Denis Voituron Date: Sat, 19 Apr 2025 11:47:54 +0200 Subject: [PATCH 3/9] Add ToTimeAgo extension method --- src/Core/Components/Base/FluentInputBase.cs | 2 +- .../Layout/FluentLayoutHamburger.razor.cs | 1 - src/Core/Extensions/DateTimeExtensions.cs | 32 +++++++++-------- src/Core/Localization/LanguageResource.resx | 36 +++++++++++++++++++ ...soft.FluentUI.AspNetCore.Components.csproj | 2 +- 5 files changed, 55 insertions(+), 18 deletions(-) diff --git a/src/Core/Components/Base/FluentInputBase.cs b/src/Core/Components/Base/FluentInputBase.cs index 60a337a652..bcffad011b 100644 --- a/src/Core/Components/Base/FluentInputBase.cs +++ b/src/Core/Components/Base/FluentInputBase.cs @@ -211,7 +211,7 @@ protected virtual async Task ChangeHandlerAsync(ChangeEventArgs e) protected virtual string? GetAriaLabelWithRequired() { return (AriaLabel ?? Label ?? string.Empty) + - (Required == true ? $", {Localizer["FluentInputBase_Required"]}" : string.Empty); + (Required == true ? $", {Localizer[Localization.LanguageResource.FluentInputBase_Required]}" : string.Empty); } /// diff --git a/src/Core/Components/Layout/FluentLayoutHamburger.razor.cs b/src/Core/Components/Layout/FluentLayoutHamburger.razor.cs index 1025cd8374..a3260bf777 100644 --- a/src/Core/Components/Layout/FluentLayoutHamburger.razor.cs +++ b/src/Core/Components/Layout/FluentLayoutHamburger.razor.cs @@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; -using Microsoft.FluentUI.AspNetCore.Components.Localization; namespace Microsoft.FluentUI.AspNetCore.Components; diff --git a/src/Core/Extensions/DateTimeExtensions.cs b/src/Core/Extensions/DateTimeExtensions.cs index 00d65479e6..fe8229ea46 100644 --- a/src/Core/Extensions/DateTimeExtensions.cs +++ b/src/Core/Extensions/DateTimeExtensions.cs @@ -3,6 +3,7 @@ // ------------------------------------------------------------------------ using System.Globalization; +using LangResx = Microsoft.FluentUI.AspNetCore.Components.Localization.LanguageResource; namespace Microsoft.FluentUI.AspNetCore.Components.Extensions; @@ -122,27 +123,24 @@ public static DateTime StartOfWeek(this DateTime self) return StartOfWeek(self, CultureInfo.CurrentUICulture); } - /* /// /// Get a string showing how long ago a DateTime was, for example '4 minutes ago'. /// /// - /// + /// /// /// Inspired from https://github.com/NickStrupat/TimeAgo. - public static string ToTimeAgo(this TimeSpan delay, TimeAgoOptions? resources = null) + public static string ToTimeAgo(this TimeSpan delay, IFluentLocalizer? localizer = null) { const int MAX_SECONDS_FOR_JUST_NOW = 10; - if (resources == null) - { - resources = new TimeAgoOptions(); - } + // Use the default localizer if none is provided + localizer = localizer ?? FluentLocalizerInternal.Default; if (delay.Days > 365) { var years = Math.Round(decimal.Divide(delay.Days, 365)); - return string.Format(years == 1 ? resources.YearAgo : resources.YearsAgo, years); + return Pluralize(years, LangResx.TimeAgo_YearAgo, LangResx.TimeAgo_YearsAgo); } if (delay.Days > 30) @@ -153,37 +151,41 @@ public static string ToTimeAgo(this TimeSpan delay, TimeAgoOptions? resources = months += 1; } - return string.Format(months == 1 ? resources.MonthAgo : resources.MonthsAgo, months); + return Pluralize(months, LangResx.TimeAgo_MonthAgo, LangResx.TimeAgo_MonthsAgo); } if (delay.Days > 0) { - return string.Format(delay.Days == 1 ? resources.DayAgo : resources.DaysAgo, delay.Days); + return Pluralize(delay.Days, LangResx.TimeAgo_DayAgo, LangResx.TimeAgo_DaysAgo); } if (delay.Hours > 0) { - return string.Format(delay.Hours == 1 ? resources.HourAgo : resources.HoursAgo, delay.Hours); + return Pluralize(delay.Hours, LangResx.TimeAgo_HourAgo, LangResx.TimeAgo_HoursAgo); } if (delay.Minutes > 0) { - return string.Format(delay.Minutes == 1 ? resources.MinuteAgo : resources.MinutesAgo, delay.Minutes); + return Pluralize(delay.Minutes, LangResx.TimeAgo_MinuteAgo, LangResx.TimeAgo_MinutesAgo); } if (delay.Seconds > MAX_SECONDS_FOR_JUST_NOW) { - return string.Format(resources.SecondsAgo, delay.Seconds); + return string.Format(CultureInfo.InvariantCulture, localizer[LangResx.TimeAgo_SecondAgo], delay.Seconds); } if (delay.Seconds <= MAX_SECONDS_FOR_JUST_NOW) { - return string.Format(resources.SecondAgo, delay.Seconds); + return string.Format(CultureInfo.InvariantCulture, localizer[LangResx.TimeAgo_SecondsAgo], delay.Seconds); } throw new NotSupportedException("The DateTime object does not have a supported value."); + + string Pluralize(decimal count, string singular, string plural) + { + return string.Format(CultureInfo.InvariantCulture, count == 1 ? localizer[singular] : localizer[plural], count); + } } - */ /// /// Converts the DateOnly to an equivalent DateTime. diff --git a/src/Core/Localization/LanguageResource.resx b/src/Core/Localization/LanguageResource.resx index 2b4f8ca270..03860372ab 100644 --- a/src/Core/Localization/LanguageResource.resx +++ b/src/Core/Localization/LanguageResource.resx @@ -165,4 +165,40 @@ Please, check this value + + {0} day ago + + + {0} days ago + + + {0} hour ago + + + {0} hours ago + + + {0} minute ago + + + {0} minutes ago + + + {0} month ago + + + {0} months ago + + + Just now + + + {0} seconds ago + + + {0} year ago + + + {0} years ago + \ No newline at end of file diff --git a/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj b/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj index cef5fa9602..4a8587757f 100644 --- a/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj +++ b/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj @@ -110,7 +110,7 @@ - true + false true true CS1591 From 1b8bbcaeb2040851bc1486d3ce0c15c59cfb27c2 Mon Sep 17 00:00:00 2001 From: Denis Voituron Date: Sat, 19 Apr 2025 16:26:51 +0200 Subject: [PATCH 4/9] Update the Resc Generator version and the doc --- Directory.Packages.props | 2 +- .../Documentation/GetStarted/Localization.md | 4 +++- src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index e17ca9a0a7..9110681974 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -31,7 +31,7 @@ - + diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Localization.md b/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Localization.md index 72f9f806a1..e1955ca423 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Localization.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Localization.md @@ -46,7 +46,9 @@ Here's a step-by-step guide: > **Note:** > - > The list of keys can be found in the `Core\Microsoft.FluentUI.AspNetCore.Components\Localization\LanguageResource.resx` file. + > The list of keys can be found in the `Core\Microsoft.FluentUI.AspNetCore.Components\Localization\LanguageResource.resx` file. + > Or you can use a constant from the `Microsoft.FluentUI.AspNetCore.Components.Localization.LanguageResource` class. + > Example: `Localization.LanguageResource.MessageBox_ButtonOk`. 2. **Register the Custom Localizer** diff --git a/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj b/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj index 4a8587757f..cef5fa9602 100644 --- a/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj +++ b/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj @@ -110,7 +110,7 @@ - false + true true true CS1591 From aff4a8681f02ee16f629b20a3dae33d762e5f697 Mon Sep 17 00:00:00 2001 From: Denis Voituron Date: Sat, 19 Apr 2025 16:51:41 +0200 Subject: [PATCH 5/9] Add Unit Tests --- .../Documentation/GetStarted/Localization.md | 4 +- spelling.dic | 6 + src/Core/Extensions/DateTimeExtensions.cs | 4 +- .../Extensions/DateTimeExtensionsTests.cs | 193 ++++++++++++++++++ .../DateTimeExtensionsToTimeAgoTests.cs | 95 +++++++++ 5 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 tests/Core/Extensions/DateTimeExtensionsTests.cs create mode 100644 tests/Core/Extensions/DateTimeExtensionsToTimeAgoTests.cs diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Localization.md b/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Localization.md index e1955ca423..b03dd6b3c8 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Localization.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Localization.md @@ -34,10 +34,10 @@ Here's a step-by-step guide: return key switch { "SomeKey" => "Your Custom Translation", - "AnotherKey" => String.Format("Another Custom Translation {0}", + "AnotherKey" => String.Format("Another Custom Translation {0}"), // Fallback to the Default/English if no translation is found - _ => IFluentLocalizer.GetDefault(key, arguments) + _ => IFluentLocalizer.GetDefault(key, arguments), }; } } diff --git a/spelling.dic b/spelling.dic index 3e88d621c8..d45e03f695 100644 --- a/spelling.dic +++ b/spelling.dic @@ -15,7 +15,11 @@ datalist elementreference evenodd gzip +heure +heures javascript +jours +maintenant menuchecked menuclicked menuitem @@ -26,6 +30,7 @@ menuitemradio menuitemradioobsolete menuitems microsoft +mois myid noattribute nonfile @@ -45,6 +50,7 @@ demopanel dialogtoggle rightclick rrggbb +secondes sourcecode summarydata tabindex diff --git a/src/Core/Extensions/DateTimeExtensions.cs b/src/Core/Extensions/DateTimeExtensions.cs index fe8229ea46..f8e03c6123 100644 --- a/src/Core/Extensions/DateTimeExtensions.cs +++ b/src/Core/Extensions/DateTimeExtensions.cs @@ -171,12 +171,12 @@ public static string ToTimeAgo(this TimeSpan delay, IFluentLocalizer? localizer if (delay.Seconds > MAX_SECONDS_FOR_JUST_NOW) { - return string.Format(CultureInfo.InvariantCulture, localizer[LangResx.TimeAgo_SecondAgo], delay.Seconds); + return string.Format(CultureInfo.InvariantCulture, localizer[LangResx.TimeAgo_SecondsAgo], delay.Seconds); } if (delay.Seconds <= MAX_SECONDS_FOR_JUST_NOW) { - return string.Format(CultureInfo.InvariantCulture, localizer[LangResx.TimeAgo_SecondsAgo], delay.Seconds); + return string.Format(CultureInfo.InvariantCulture, localizer[LangResx.TimeAgo_SecondAgo], delay.Seconds); } throw new NotSupportedException("The DateTime object does not have a supported value."); diff --git a/tests/Core/Extensions/DateTimeExtensionsTests.cs b/tests/Core/Extensions/DateTimeExtensionsTests.cs new file mode 100644 index 0000000000..a614b79716 --- /dev/null +++ b/tests/Core/Extensions/DateTimeExtensionsTests.cs @@ -0,0 +1,193 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System.Globalization; +using Microsoft.FluentUI.AspNetCore.Components.Extensions; +using Xunit; + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Extensions; + +public class DateTimeExtensionsTests +{ + [Fact] + public void DateTimeExtensions_ToIsoDateString_NullDate_ReturnsEmptyString() + { + DateTime? date = null; + var result = date.ToIsoDateString(); + Assert.Equal(string.Empty, result); + } + + [Fact] + public void DateTimeExtensions_ToIsoDateString_ValidDate_ReturnsIsoFormattedString() + { + DateTime? date = new DateTime(2025, 4, 19); + var result = date.ToIsoDateString(); + Assert.Equal("2025-04-19", result); + } + + [Fact] + public void DateTimeExtensions_StartOfMonth_ValidDate_ReturnsFirstDayOfMonth() + { + var date = new DateTime(2025, 4, 19); + var culture = CultureInfo.InvariantCulture; + var result = date.StartOfMonth(culture); + Assert.Equal(new DateTime(2025, 4, 1), result); + } + + [Fact] + public void DateTimeExtensions_StartOfYear_ValidDate_ReturnsFirstDayOfYear() + { + var date = new DateTime(2025, 4, 19); + var culture = CultureInfo.InvariantCulture; + var result = date.StartOfYear(culture); + Assert.Equal(new DateTime(2025, 1, 1), result); + } + + [Fact] + public void DateTimeExtensions_EndOfYear_ValidDate_ReturnsLastDayOfYear() + { + var date = new DateTime(2025, 4, 19); + var culture = CultureInfo.InvariantCulture; + var result = date.EndOfYear(culture); + Assert.Equal(new DateTime(2025, 12, 31), result); + } + + [Fact] + public void DateTimeExtensions_EndOfMonth_ValidDate_ReturnsLastDayOfMonth() + { + var date = new DateTime(2025, 4, 19); + var culture = CultureInfo.InvariantCulture; + var result = date.EndOfMonth(culture); + Assert.Equal(new DateTime(2025, 4, 30), result); + } + + [Fact] + public void DateTimeExtensions_StartOfWeek_ValidDate_ReturnsFirstDayOfWeek() + { + var date = new DateTime(2025, 4, 19); // Saturday + var result = date.StartOfWeek(DayOfWeek.Monday); + Assert.Equal(new DateTime(2025, 4, 14), result); // Monday + } + + [Fact] + public void DateTimeExtensions_StartOfWeek_WithCulture_ReturnsFirstDayOfWeek() + { + var date = new DateTime(2025, 4, 19); // Saturday + var culture = CultureInfo.InvariantCulture; + var result = date.StartOfWeek(culture); + Assert.Equal(new DateTime(2025, 4, 13), result); // Sunday (default first day in InvariantCulture) + } + + [Fact] + public void DateTimeExtensions_ToTimeAgo_ValidTimeSpan_ReturnsCorrectString() + { + var delay = TimeSpan.FromMinutes(5); + var result = delay.ToTimeAgo(); + Assert.Contains("5 minutes ago", result); + } + + [Fact] + public void DateTimeExtensions_ToDateTime_DateOnly_ReturnsDateTime() + { + var dateOnly = new DateOnly(2025, 4, 19); + var result = dateOnly.ToDateTime(); + Assert.Equal(new DateTime(2025, 4, 19), result); + } + + [Fact] + public void DateTimeExtensions_ToDateTime_TimeOnly_ReturnsDateTime() + { + var timeOnly = new TimeOnly(14, 30); + var result = timeOnly.ToDateTime(); + Assert.Equal(new DateTime(1, 1, 1, 14, 30, 0), result); + } + + [Fact] + public void DateTimeExtensions_ToDateTimeNullable_NullDateOnly_ReturnsNull() + { + DateOnly? dateOnly = null; + var result = dateOnly.ToDateTimeNullable(); + Assert.Null(result); + } + + [Fact] + public void DateTimeExtensions_ToDateOnly_NullDateTime_ReturnsMinValue() + { + DateTime? dateTime = null; + var result = dateTime.ToDateOnly(); + Assert.Equal(DateOnly.MinValue, result); + } + + [Fact] + public void DateTimeExtensions_ToTimeOnly_NullDateTime_ReturnsMinValue() + { + DateTime? dateTime = null; + var result = dateTime.ToTimeOnly(); + Assert.Equal(TimeOnly.MinValue, result); + } + + [Fact] + public void DateTimeExtensions_GetYear_ValidDate_ReturnsYear() + { + var date = new DateTime(2025, 4, 19); + var culture = CultureInfo.InvariantCulture; + var result = date.GetYear(culture); + Assert.Equal(2025, result); + } + + [Fact] + public void DateTimeExtensions_GetMonth_ValidDate_ReturnsMonth() + { + var date = new DateTime(2025, 4, 19); + var culture = CultureInfo.InvariantCulture; + var result = date.GetMonth(culture); + Assert.Equal(4, result); + } + + [Fact] + public void DateTimeExtensions_GetDay_ValidDate_ReturnsDay() + { + var date = new DateTime(2025, 4, 19); + var culture = CultureInfo.InvariantCulture; + var result = date.GetDay(culture); + Assert.Equal(19, result); + } + + [Fact] + public void DateTimeExtensions_AddDays_ValidDate_AddsDays() + { + var date = new DateTime(2025, 4, 19); + var culture = CultureInfo.InvariantCulture; + var result = date.AddDays(5, culture); + Assert.Equal(new DateTime(2025, 4, 24), result); + } + + [Fact] + public void DateTimeExtensions_AddMonths_ValidDate_AddsMonths() + { + var date = new DateTime(2025, 4, 19); + var culture = CultureInfo.InvariantCulture; + var result = date.AddMonths(2, culture); + Assert.Equal(new DateTime(2025, 6, 19), result); + } + + [Fact] + public void DateTimeExtensions_AddYears_ValidDate_AddsYears() + { + var date = new DateTime(2025, 4, 19); + var culture = CultureInfo.InvariantCulture; + var result = date.AddYears(1, culture); + Assert.Equal(new DateTime(2026, 4, 19), result); + } + + [Fact] + public void DateTimeExtensions_GetMonthName_ValidDate_ReturnsMonthName() + { + var date = new DateTime(2025, 4, 19); + var culture = CultureInfo.InvariantCulture; + var result = date.GetMonthName(culture); + Assert.Equal("April", result); + } +} + diff --git a/tests/Core/Extensions/DateTimeExtensionsToTimeAgoTests.cs b/tests/Core/Extensions/DateTimeExtensionsToTimeAgoTests.cs new file mode 100644 index 0000000000..4cf7c46049 --- /dev/null +++ b/tests/Core/Extensions/DateTimeExtensionsToTimeAgoTests.cs @@ -0,0 +1,95 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.FluentUI.AspNetCore.Components.Extensions; +using Xunit; +using LanguageResource = Microsoft.FluentUI.AspNetCore.Components.Localization.LanguageResource; + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Extensions; + +public class ToTimeAgoTests +{ + [Theory] + [InlineData("000.00:00:02", "Just now")] + [InlineData("000.00:00:25", "25 seconds ago")] + [InlineData("000.00:01:00", "1 minute ago")] + [InlineData("000.00:05:00", "5 minutes ago")] + [InlineData("000.01:00:00", "1 hour ago")] + [InlineData("000.05:00:00", "5 hours ago")] + [InlineData("001.00:00:00", "1 day ago")] + [InlineData("005.00:00:00", "5 days ago")] + [InlineData("031.00:00:00", "1 month ago")] + [InlineData("035.00:00:00", "2 months ago")] + [InlineData("150.00:00:00", "6 months ago")] + [InlineData("370.00:00:00", "1 year ago")] + [InlineData("740.00:00:00", "2 years ago")] + [InlineData("900.00:00:00", "2 years ago")] + [InlineData("920.00:00:00", "3 years ago")] + public void ToTimeAgo_Default(string delayAsString, string expectedValue) + { + var delay = TimeSpan.ParseExact(delayAsString, @"ddd\.hh\:mm\:ss", null); + + Assert.Equal(expectedValue, delay.ToTimeAgo()); + } + + [Theory] + [InlineData("000.00:00:02", "Maintenant")] + [InlineData("000.00:00:25", "Il y a 25 secondes")] + [InlineData("000.00:01:00", "Il y a une minute")] + [InlineData("000.00:05:00", "Il y a 5 minutes")] + [InlineData("000.01:00:00", "Il y a une heure")] + [InlineData("000.05:00:00", "Il y a 5 heures")] + [InlineData("001.00:00:00", "Il y a un jour")] + [InlineData("005.00:00:00", "Il y a 5 jours")] + [InlineData("031.00:00:00", "Il y a un mois")] + [InlineData("035.00:00:00", "Il y a 2 mois")] + [InlineData("150.00:00:00", "Il y a 6 mois")] + [InlineData("370.00:00:00", "Il y a 1 an")] + [InlineData("740.00:00:00", "Il y a 2 ans")] + [InlineData("900.00:00:00", "Il y a 2 ans")] + [InlineData("920.00:00:00", "Il y a 3 ans")] + public void ToTimeAgo_Customized(string delayAsString, string expectedValue) + { + var delay = TimeSpan.ParseExact(delayAsString, @"ddd\.hh\:mm\:ss", null); + Assert.Equal(expectedValue, delay.ToTimeAgo(new FrenchTimeAgoLocalizer())); + } + + [Theory] + [InlineData("000.00:00:02", "Just now")] + public void ToTimeAgo_Ctor(string delayAsString, string expectedValue) + { + var delay = TimeSpan.ParseExact(delayAsString, @"ddd\.hh\:mm\:ss", null); + + Assert.Equal(expectedValue, delay.ToTimeAgo(localizer: null)); + } + + private class FrenchTimeAgoLocalizer : IFluentLocalizer + { + public string this[string key, params object[] arguments] + { + get + { + // Provide custom translations based on the key + return key switch + { + LanguageResource.TimeAgo_SecondAgo => "Maintenant", + LanguageResource.TimeAgo_SecondsAgo => "Il y a {0} secondes", + LanguageResource.TimeAgo_MinuteAgo => "Il y a une minute", + LanguageResource.TimeAgo_MinutesAgo => "Il y a {0} minutes", + LanguageResource.TimeAgo_HourAgo => "Il y a une heure", + LanguageResource.TimeAgo_HoursAgo => "Il y a {0} heures", + LanguageResource.TimeAgo_DayAgo => "Il y a un jour", + LanguageResource.TimeAgo_DaysAgo => "Il y a {0} jours", + LanguageResource.TimeAgo_MonthAgo => "Il y a un mois", + LanguageResource.TimeAgo_MonthsAgo => "Il y a {0} mois", + LanguageResource.TimeAgo_YearAgo => "Il y a {0} an", + LanguageResource.TimeAgo_YearsAgo => "Il y a {0} ans", + + // Fallback to the Default/English if no translation is found + _ => IFluentLocalizer.GetDefault(key, arguments), + }; + } + } + } +} From 25ff1556f8411c241fbcc439fb30f1a4656b32fa Mon Sep 17 00:00:00 2001 From: Denis Voituron Date: Sat, 19 Apr 2025 17:09:25 +0200 Subject: [PATCH 6/9] Add Unit Tests --- src/Core/Extensions/DateTimeExtensions.cs | 7 +- .../Extensions/DateTimeExtensionsTests.cs | 110 ++++++++++++++++++ 2 files changed, 111 insertions(+), 6 deletions(-) diff --git a/src/Core/Extensions/DateTimeExtensions.cs b/src/Core/Extensions/DateTimeExtensions.cs index f8e03c6123..91ce6fd500 100644 --- a/src/Core/Extensions/DateTimeExtensions.cs +++ b/src/Core/Extensions/DateTimeExtensions.cs @@ -174,12 +174,7 @@ public static string ToTimeAgo(this TimeSpan delay, IFluentLocalizer? localizer return string.Format(CultureInfo.InvariantCulture, localizer[LangResx.TimeAgo_SecondsAgo], delay.Seconds); } - if (delay.Seconds <= MAX_SECONDS_FOR_JUST_NOW) - { - return string.Format(CultureInfo.InvariantCulture, localizer[LangResx.TimeAgo_SecondAgo], delay.Seconds); - } - - throw new NotSupportedException("The DateTime object does not have a supported value."); + return string.Format(CultureInfo.InvariantCulture, localizer[LangResx.TimeAgo_SecondAgo], delay.Seconds); string Pluralize(decimal count, string singular, string plural) { diff --git a/tests/Core/Extensions/DateTimeExtensionsTests.cs b/tests/Core/Extensions/DateTimeExtensionsTests.cs index a614b79716..8ecb2bbad6 100644 --- a/tests/Core/Extensions/DateTimeExtensionsTests.cs +++ b/tests/Core/Extensions/DateTimeExtensionsTests.cs @@ -79,6 +79,32 @@ public void DateTimeExtensions_StartOfWeek_WithCulture_ReturnsFirstDayOfWeek() Assert.Equal(new DateTime(2025, 4, 13), result); // Sunday (default first day in InvariantCulture) } + [Fact] + public void DateTimeExtensions_StartOfWeek_Min() + { + var date = DateTime.MinValue; // January 1, 0001 + var result = date.StartOfWeek(DayOfWeek.Wednesday); + Assert.Equal(new DateTime(1, 1, 1), result); + } + + [Fact] + public void DateTimeExtensions_StartOfWeek_WithCulture_Min() + { + var date = DateTime.MinValue; // January 1, 0001 + var culture = CultureInfo.InvariantCulture; + var result = date.StartOfWeek(culture); + Assert.Equal(new DateTime(1, 1, 1), result); + } + + [Fact] + public void DateTimeExtensions_StartOfWeek_NoParameters_ReturnsFirstDayOfWeek() + { + var date = new DateTime(2025, 4, 19); // Saturday + var result = date.StartOfWeek(); + var expected = date.StartOfWeek(CultureInfo.CurrentUICulture); + Assert.Equal(expected, result); + } + [Fact] public void DateTimeExtensions_ToTimeAgo_ValidTimeSpan_ReturnsCorrectString() { @@ -189,5 +215,89 @@ public void DateTimeExtensions_GetMonthName_ValidDate_ReturnsMonthName() var result = date.GetMonthName(culture); Assert.Equal("April", result); } + + [Fact] + public void DateTimeExtensions_ToDateTime_NullTimeOnly_ReturnsMinValue() + { + TimeOnly? timeOnly = null; + var result = timeOnly.ToDateTime(); + Assert.Equal(DateTime.MinValue, result); + } + + [Fact] + public void DateTimeExtensions_ToDateTime_NullableDateOnly_ReturnsCorrectDateTime() + { + // Test with null DateOnly + DateOnly? nullDateOnly = null; + var result = nullDateOnly.ToDateTime(); + Assert.Equal(DateTime.MinValue, result); + + // Test with valid DateOnly + DateOnly? validDateOnly = new DateOnly(2025, 4, 19); + result = validDateOnly.ToDateTime(); + Assert.Equal(new DateTime(2025, 4, 19), result); + } + + [Fact] + public void DateTimeExtensions_ToDateTimeNullable_ValidTimeOnly_ReturnsDateTime() + { + TimeOnly? timeOnly = new TimeOnly(14, 30); + var result = timeOnly.ToDateTimeNullable(); + Assert.Equal(new DateTime(1, 1, 1, 14, 30, 0), result); + } + + [Fact] + public void DateTimeExtensions_ToDateTimeNullable_NullTimeOnly_ReturnsNull() + { + TimeOnly? timeOnly = null; + var result = timeOnly.ToDateTimeNullable(); + Assert.Null(result); + } + + [Fact] + public void DateTimeExtensions_ToDateTime_NullableDateTime_ReturnsCorrectDateTime() + { + // Test with null DateTime + DateTime? nullDateTime = null; + var result = nullDateTime.ToDateTime(); + Assert.Equal(DateTime.MinValue, result); + + // Test with valid DateTime + DateTime? validDateTime = new DateTime(2025, 4, 19); + result = validDateTime.ToDateTime(); + Assert.Equal(new DateTime(2025, 4, 19), result); + } + + [Fact] + public void DateTimeExtensions_ToDateOnlyNullable_NullDateTime_ReturnsNull() + { + DateTime? dateTime = null; + var result = dateTime.ToDateOnlyNullable(); + Assert.Null(result); + } + + [Fact] + public void DateTimeExtensions_ToDateOnlyNullable_ValidDateTime_ReturnsDateOnly() + { + DateTime? dateTime = new DateTime(2025, 4, 19); + var result = dateTime.ToDateOnlyNullable(); + Assert.Equal(new DateOnly(2025, 4, 19), result); + } + + [Fact] + public void DateTimeExtensions_ToTimeOnlyNullable_NullDateTime_ReturnsNull() + { + DateTime? dateTime = null; + var result = dateTime.ToTimeOnlyNullable(); + Assert.Null(result); + } + + [Fact] + public void DateTimeExtensions_ToTimeOnlyNullable_ValidDateTime_ReturnsTimeOnly() + { + DateTime? dateTime = new DateTime(2025, 4, 19, 14, 30, 0); + var result = dateTime.ToTimeOnlyNullable(); + Assert.Equal(new TimeOnly(14, 30), result); + } } From b44a500d0a97f7fae70060b135547fc160663895 Mon Sep 17 00:00:00 2001 From: Denis Voituron Date: Sat, 19 Apr 2025 17:18:25 +0200 Subject: [PATCH 7/9] Add Unit Tests --- tests/Core/Utilities/DateTimeProviderTests.cs | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 tests/Core/Utilities/DateTimeProviderTests.cs diff --git a/tests/Core/Utilities/DateTimeProviderTests.cs b/tests/Core/Utilities/DateTimeProviderTests.cs new file mode 100644 index 0000000000..f87dceceb0 --- /dev/null +++ b/tests/Core/Utilities/DateTimeProviderTests.cs @@ -0,0 +1,162 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Utilities; + +public class DateTimeProviderTests +{ + public DateTimeProviderTests(ITestOutputHelper testOutputHelper) + { + DateTimeProvider.RequiredActiveContext = true; + } + + [Fact] + public void DateTimeProvider_WithContext() + { + using var context = new DateTimeProviderContext(new DateTime(2020, 5, 26)); + + var year = MyUserClass.GetCurrentYear(); + + Assert.Equal(2020, year); + } + + [Fact] + public void DateTimeProvider_WithoutContext() + { + Assert.Throws(() => + { + var year = MyUserClass.GetCurrentYear(); + }); + } + + [Fact] + public void DateTimeProvider_SystemDate_WithRequiredContext() + { + Assert.Throws(() => + { + var date = DateTimeProvider.GetSystemDate(requiredContext: true); + }); + } + + [Fact] + public void DateTimeProvider_SystemDate_WithoutRequiredContext() + { + var date = DateTimeProvider.GetSystemDate(requiredContext: false); + + Assert.Equal(DateTime.Today.Year, date.Date.Year); + } + + [Fact] + public void DateTimeProvider_DisposeEmptyContext() + { + using var context = new DateTimeProviderContext(new DateTime(2020, 5, 26)); + + context.Dispose(); + context.Dispose(); + } + + [Fact] + public void DateTimeProvider_UtcNow() + { + var date = new DateTime(2020, 5, 26); + var currentOffset = Math.Abs(new DateTimeOffset(date).Offset.TotalHours); + + using var context = new DateTimeProviderContext(date); + var contextOffset = Math.Abs((DateTimeProvider.UtcNow - DateTimeProvider.Now).TotalHours); + + Assert.Equal(currentOffset, contextOffset); + } + + [Fact] + public void DateTimeProvider_ResetCurrentIndex() + { + const uint maxValue = uint.MaxValue; + + var currentIndex = 0u; + using var context = new DateTimeProviderContext(i => + { + currentIndex = i; + return new DateTime(2020, 5, 26); + }); + + context.ForceNextValue(maxValue); + + // First call => Max value + _ = DateTimeProvider.Today; + Assert.Equal(maxValue, currentIndex); + + // Second call => Reset + _ = DateTimeProvider.Today; + Assert.Equal(0u, currentIndex); + } + + [Fact] + public void DateTimeProvider_SimpleTest() + { + const int year = 2020; + + // Context 1 + using var context1 = new DateTimeProviderContext(new DateTime(year, 5, 26)); + Assert.Equal(year, DateTimeProvider.Today.Year); + + using (var context2 = new DateTimeProviderContext(new DateTime(year + 1, 5, 26))) + { + // Context 2 + Assert.Equal(year + 1, DateTimeProvider.Today.Year); + } + + // Context 1 + Assert.Equal(year, DateTimeProvider.Today.Year); + } + + [Fact] + public void DateTimeProvider_Sequence() + { + const int year = 2020; + + // Context Sequence + using var contextSequence = new DateTimeProviderContext(i => i switch + { + 0 => new DateTime(year + 10, 5, 26), + 1 => new DateTime(year + 11, 5, 27), + _ => DateTime.MinValue, + }); + + Assert.Equal(year + 10, DateTimeProvider.Today.Year); // Sequence 0 + Assert.Equal(year + 11, DateTimeProvider.Today.Year); // Sequence 1 + } + + [Fact] + public void DateTimeProvider_UsingListOfDates() + { + const int year = 2020; + + // Context Sequence + using var contextSequence = new DateTimeProviderContext( + [ + new DateTime(year + 10, 5, 26), + new DateTime(year + 11, 5, 27) + ]); + + Assert.Equal(year + 10, DateTimeProvider.Today.Year); // Sequence 0 + Assert.Equal(year + 11, DateTimeProvider.Today.Year); // Sequence 1 + + Assert.Throws(() => DateTimeProvider.Today); // No more dates are available + } + + private class MyUserClass + { + public static int GetCurrentYear() + { + return DateTimeProvider.Today.Year; + } + } +} From eb94f69e48968c67ef85d30ddd719b09a1c2f8c2 Mon Sep 17 00:00:00 2001 From: Denis Voituron Date: Sat, 19 Apr 2025 17:28:50 +0200 Subject: [PATCH 8/9] Add Unit Tests --- tests/Core/Utilities/DateTimeProviderTests.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/Core/Utilities/DateTimeProviderTests.cs b/tests/Core/Utilities/DateTimeProviderTests.cs index f87dceceb0..549b521c93 100644 --- a/tests/Core/Utilities/DateTimeProviderTests.cs +++ b/tests/Core/Utilities/DateTimeProviderTests.cs @@ -151,6 +151,19 @@ public void DateTimeProvider_UsingListOfDates() Assert.Throws(() => DateTimeProvider.Today); // No more dates are available } + [Fact] + public void DateTimeProviderContext_RecordTypeTest() + { + // Arrange + var date = new DateTime(2020, 5, 26); + + // Act + using var context = new DateTimeProviderContext(date); + + // Assert + Assert.NotNull(context); + Assert.IsType(context); + } private class MyUserClass { From 6b84874959d89f1ba9e846f0195b83e6426e11b4 Mon Sep 17 00:00:00 2001 From: Denis Voituron Date: Sat, 19 Apr 2025 20:40:00 +0200 Subject: [PATCH 9/9] Fix PR comment --- src/Core/Localization/ReadMe.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Localization/ReadMe.md b/src/Core/Localization/ReadMe.md index e5fbf0e60b..941d40f1d0 100644 --- a/src/Core/Localization/ReadMe.md +++ b/src/Core/Localization/ReadMe.md @@ -4,7 +4,7 @@ Localization allows the text in some components to be translated. ## Explanation -**Fluent UI Blazor** itself provides English language strings for texts found in. +**Fluent UI Blazor** itself provides English language strings for texts found in its components. To customize translations in **Fluent UI Blazor**, the developer can register a custom `IFluentLocalizer` implementation as a service, and register this custom localizer in the `Program.cs` file, during the service registration.