diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c4bed6..bde92e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,5 +15,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `IConfiguration` extension methods - `IServiceCollection` extension methods - `String` extension methods +- `DateTime` extension methods - `IFormFile` extension method (AspNetCore package only) - `DbSet` extension methods for ISortableEntity interface (EntityFrameworkCore package only) diff --git a/Neolution.Utilities.UnitTests/Extensions/DateTimeExtensionsTests.cs b/Neolution.Utilities.UnitTests/Extensions/DateTimeExtensionsTests.cs new file mode 100644 index 0000000..e11d7f4 --- /dev/null +++ b/Neolution.Utilities.UnitTests/Extensions/DateTimeExtensionsTests.cs @@ -0,0 +1,181 @@ +namespace Neolution.Utilities.UnitTests.Extensions; + +using System.Diagnostics.CodeAnalysis; + +/// +/// Unit tests for the class. +/// +public class DateTimeExtensionsTests +{ + /// + /// Tests the parameter-less method which requires the time to be exactly 00:00:00. + /// + /// The year component. + /// The month component. + /// The day component. + /// The hour component. + /// The minute component. + /// The second component. + /// Expected result indicating whether it is exactly midnight. + [Theory] + [InlineData(2025, 1, 10, 0, 0, 0, true)] // Exact midnight + [InlineData(2025, 1, 10, 0, 0, 1, false)] // Midnight + 1 second + [InlineData(2025, 1, 10, 0, 1, 0, false)] // 00:01 + [InlineData(2025, 1, 10, 1, 0, 0, false)] // 01:00 + [InlineData(2025, 1, 10, 23, 59, 59, false)] // End of day just before midnight + public void GivenDateTime_WhenIsMidnightCalled_ThenReturnsExpected( + int year, int month, int day, int hour, int minute, int second, bool expected) + { + // Arrange + var dateTime = new DateTime(year, month, day, hour, minute, second, DateTimeKind.Unspecified); + + // Act + var result = dateTime.IsMidnight(); + + // Assert + result.ShouldBe(expected); + } + + /// + /// Tests with the ignoreSeconds flag. + /// Ensures midnight detection works both strictly and when seconds are ignored. + /// + /// Hour component. + /// Minute component. + /// Second component. + /// Flag indicating whether to ignore seconds. + /// Expected result given the flag. + [Theory] + [InlineData(0, 0, 0, false, true)] // Exact midnight strict + [InlineData(0, 0, 1, false, false)] // Not exact midnight strict + [InlineData(0, 0, 1, true, true)] // Midnight with seconds ignored + [InlineData(0, 1, 0, true, false)] // Minute > 0 cannot be midnight + [InlineData(1, 0, 0, true, false)] // Hour > 0 cannot be midnight + public void GivenDateTimeAndIgnoreSecondsFlag_WhenIsMidnightCalled_ThenReturnsExpected( + int hour, int minute, int second, bool ignoreSeconds, bool expected) + { + // Arrange + var dateTime = new DateTime(2025, 5, 20, hour, minute, second, DateTimeKind.Unspecified); + + // Act + var result = dateTime.IsMidnight(ignoreSeconds); + + // Assert + result.ShouldBe(expected); + } + + /// + /// Tests with various months including leap year February. + /// + /// Year component. + /// Month component. + /// Day component. + /// Expected result (true if end of month). + [Theory] + [InlineData(2025, 1, 31, true)] // 31-day month end + [InlineData(2025, 1, 30, false)] + [InlineData(2024, 2, 29, true)] // Leap year February + [InlineData(2024, 2, 28, false)] + [InlineData(2025, 2, 28, true)] // Non-leap year February + [InlineData(2025, 2, 27, false)] + [InlineData(2025, 4, 30, true)] // 30-day month end + [InlineData(2025, 4, 29, false)] + public void GivenDateTime_WhenIsEndOfMonthCalled_ThenReturnsExpected( + int year, int month, int day, bool expected) + { + // Arrange + var dateTime = new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Unspecified); + + // Act + var result = dateTime.IsEndOfMonth(); + + // Assert + result.ShouldBe(expected); + } + + /// + /// Gets the test data for method. + /// + [SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1201:Elements should appear in the correct order", Justification = "It's better to have test data next to the test method")] + public static TheoryData GivenTargetAndRange_WhenIsInRangeCalled_ThenReturnsExpected_TestData => new() + { + { + // Inside range + new DateTime(2025, 5, 15, 0, 0, 0, DateTimeKind.Unspecified), + new DateTime(2025, 5, 14, 0, 0, 0, DateTimeKind.Unspecified), + new DateTime(2025, 5, 16, 0, 0, 0, DateTimeKind.Unspecified), + true + }, + { + // Equal to start + new DateTime(2025, 5, 14, 0, 0, 0, DateTimeKind.Unspecified), + new DateTime(2025, 5, 14, 0, 0, 0, DateTimeKind.Unspecified), + new DateTime(2025, 5, 16, 0, 0, 0, DateTimeKind.Unspecified), + true + }, + { + // Equal to end + new DateTime(2025, 5, 16, 0, 0, 0, DateTimeKind.Unspecified), + new DateTime(2025, 5, 14, 0, 0, 0, DateTimeKind.Unspecified), + new DateTime(2025, 5, 16, 0, 0, 0, DateTimeKind.Unspecified), + true + }, + { + // Before start + new DateTime(2025, 5, 13, 22, 0, 0, DateTimeKind.Unspecified), + new DateTime(2025, 5, 14, 0, 0, 0, DateTimeKind.Unspecified), + new DateTime(2025, 5, 16, 0, 0, 0, DateTimeKind.Unspecified), + false + }, + { + // After end + new DateTime(2025, 5, 16, 22, 0, 0, DateTimeKind.Unspecified), + new DateTime(2025, 5, 14, 0, 0, 0, DateTimeKind.Unspecified), + new DateTime(2025, 5, 16, 0, 0, 0, DateTimeKind.Unspecified), + false + }, + { + // Single point range (match) + new DateTime(2025, 5, 15, 0, 0, 0, DateTimeKind.Unspecified), + new DateTime(2025, 5, 15, 0, 0, 0, DateTimeKind.Unspecified), + new DateTime(2025, 5, 15, 0, 0, 0, DateTimeKind.Unspecified), + true + }, + { + // Single point range (mismatch) + new DateTime(2025, 5, 14, 21, 36, 0, DateTimeKind.Unspecified), + new DateTime(2025, 5, 15, 0, 0, 0, DateTimeKind.Unspecified), + new DateTime(2025, 5, 15, 0, 0, 0, DateTimeKind.Unspecified), + false + }, + { + // Reversed range (start > end) always false + new DateTime(2025, 5, 15, 0, 0, 0, DateTimeKind.Unspecified), + new DateTime(2025, 5, 16, 0, 0, 0, DateTimeKind.Unspecified), + new DateTime(2025, 5, 14, 0, 0, 0, DateTimeKind.Unspecified), + false + }, + }; + + /// + /// Tests ensuring: + /// - Inclusive start and end boundaries. + /// - Dates before and after the range are excluded. + /// - Single-point range behavior (start == end). + /// - Reversed range (start > end) returns false. + /// + /// The target date to check. + /// The start of the range. + /// The end of the range. + /// Expected result indicating if target is in range. + [Theory] + [MemberData(nameof(GivenTargetAndRange_WhenIsInRangeCalled_ThenReturnsExpected_TestData))] + public void GivenTargetAndRange_WhenIsInRangeCalled_ThenReturnsExpected(DateTime target, DateTime startDate, DateTime endDate, bool expectedResult) + { + // Act + var result = target.IsInRange(startDate, endDate); + + // Assert + result.ShouldBe(expectedResult); + } +} diff --git a/Neolution.Utilities/Extensions/DateTimeExtensions.cs b/Neolution.Utilities/Extensions/DateTimeExtensions.cs new file mode 100644 index 0000000..ec8cb44 --- /dev/null +++ b/Neolution.Utilities/Extensions/DateTimeExtensions.cs @@ -0,0 +1,48 @@ +namespace Neolution.Utilities.Extensions; + +/// +/// The DateTime extension methods. +/// +public static class DateTimeExtensions +{ + /// + /// Determines whether the specified value is exactly at midnight (00:00:00). + /// + /// The to evaluate. + /// true if the time component is exactly 00:00:00; otherwise false. + public static bool IsMidnight(this DateTime dateTime) + => dateTime.IsMidnight(false); + + /// + /// Determines whether the specified value represents midnight (00:00), + /// optionally ignoring the seconds component. + /// + /// The to evaluate. + /// + /// If true, evaluates midnight as any time where hour and minute are zero (00:00), regardless of seconds. + /// If false, requires the time to be exactly 00:00:00. + /// + /// + /// true if the time is midnight according to the rule; otherwise false. + /// + public static bool IsMidnight(this DateTime dateTime, bool ignoreSeconds) + => ignoreSeconds ? dateTime.Hour == 0 && dateTime.Minute == 0 : dateTime.TimeOfDay.Ticks == 0; + + /// + /// Determines whether the specified value is the end of the month. + /// + /// The to evaluate. + /// true if the specified value is the end of the month; otherwise, false. + public static bool IsEndOfMonth(this DateTime dateTime) + => dateTime.Day == DateTime.DaysInMonth(dateTime.Year, dateTime.Month); + + /// + /// Determines whether the specified value is in range. + /// + /// The to evaluate. + /// The start . + /// The end . + /// true if the specified value is in range; otherwise, false. + public static bool IsInRange(this DateTime dateTime, DateTime startDate, DateTime endDate) + => dateTime >= startDate && dateTime <= endDate; +}