Skip to content

Instantly share code, notes, and snippets.

@rbeauchamp
Created May 9, 2023 19:38
Show Gist options
  • Save rbeauchamp/6d8f8ffe22358665a9dd6571b79e1d5f to your computer and use it in GitHub Desktop.
Save rbeauchamp/6d8f8ffe22358665a9dd6571b79e1d5f to your computer and use it in GitHub Desktop.
ISO 8601 Duration Parser and Test Class: A C# implementation of an ISO 8601 duration parser with comprehensive test coverage, including leap year scenarios and edge cases. Easily parse ISO 8601 duration strings into TimeSpan objects, and handle positive and negative durations.
using System.Text.RegularExpressions;
/// <summary>
/// Represents an ISO 8601 duration string parser. This parser converts ISO 8601 duration strings
/// into <see cref="TimeSpan" /> objects.
/// </summary>
/// <remarks>
/// <para>
/// The ISO 8601 duration format is a compact string representation of a duration, which can include
/// years, months, weeks, days, hours, minutes, and seconds.
/// </para>
/// <para>
/// Example usage:
/// </para>
/// <code>
/// string isoDuration = "P1Y2M3DT4H5M6.5S";
/// DateTimeOffset referenceDate = DateTimeOffset.Now;
/// TimeSpan duration = Iso8601DurationParser.ToTimeSpan(isoDuration, referenceDate);
/// </code>
/// </remarks>
public static partial class Iso8601DurationParser
{
public static TimeSpan ToTimeSpan(string iso8601Duration, DateTimeOffset referenceDate)
{
var durationRegex = Iso8601DurationRegex();
var match = durationRegex.Match(iso8601Duration);
if (!match.Success)
{
throw new FormatException($"'{iso8601Duration}' is an invalid ISO 8601 duration format.");
}
var sign = match.Groups["sign"].Success ? -1 : 1;
_ = int.TryParse(match.Groups["years"].Value, out var years);
_ = int.TryParse(match.Groups["months"].Value, out var months);
_ = int.TryParse(match.Groups["weeks"].Value, out var weeks);
_ = int.TryParse(match.Groups["days"].Value, out var days);
_ = int.TryParse(match.Groups["hours"].Value, out var hours);
_ = int.TryParse(match.Groups["minutes"].Value, out var minutes);
_ = double.TryParse(match.Groups["seconds"].Value, out var seconds);
var endDate = referenceDate
.AddYears(sign * years)
.AddMonths(sign * months)
.AddDays(7 * sign * weeks + sign * days)
.AddHours(sign * hours)
.AddMinutes(sign * minutes)
.AddSeconds(sign * seconds);
return endDate - referenceDate;
}
/// <summary>
/// Generates a <see cref="Regex" /> object for matching ISO 8601 duration strings.
/// </summary>
/// <returns>A <see cref="Regex" /> object for matching ISO 8601 duration strings.</returns>
[GeneratedRegex("^(?<sign>-)?P(?=\\d|T)(?:(?<years>\\d+)Y)?(?:(?<months>\\d+)M)?(?:(?<weeks>\\d+)W)?(?:(?<days>\\d+)D)?(T(?=\\d)(?:(?<hours>\\d+)H)?(?:(?<minutes>\\d+)M)?(?:(?<seconds>\\d+(?:[.,]\\d{1,9})?)S)?)?$",
RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture | RegexOptions.Compiled, "en-US")]
private static partial Regex Iso8601DurationRegex();
}
using Xunit;
/// <summary>
/// Contains test cases for the Iso8601DurationParser class.
/// </summary>
public class Iso8601DurationParserTests
{
// Test cases for valid ISO 8601 duration strings
[Theory]
[InlineData("P3Y6M4DT12H30M5S", "2020-01-01", "2023-07-05T12:30:05")]
[InlineData("-P3Y6M4DT12H30M5S", "2020-01-01", "2016-06-26T11:29:55")]
[InlineData("P1DT2H3M4S", "2020-01-01", "2020-01-02T02:03:04")]
[InlineData("PT1H30M", "2020-01-01", "2020-01-01T01:30:00")]
// Regular durations
[InlineData("P1Y", "2020-01-01", "2021-01-01")]
[InlineData("P1M", "2020-01-01", "2020-02-01")]
[InlineData("P1W", "2020-01-01", "2020-01-08")]
[InlineData("P1D", "2020-01-01", "2020-01-02")]
[InlineData("PT1H", "2020-01-01T00:00:00", "2020-01-01T01:00:00")]
[InlineData("PT1M", "2020-01-01T00:00:00", "2020-01-01T00:01:00")]
[InlineData("PT1S", "2020-01-01T00:00:00", "2020-01-01T00:00:01")]
// Negative durations
[InlineData("-P1Y", "2020-01-01", "2019-01-01")]
[InlineData("-P1M", "2020-01-01", "2019-12-01")]
[InlineData("-P1W", "2020-01-01", "2019-12-25")]
[InlineData("-P1D", "2020-01-01", "2019-12-31")]
[InlineData("-PT1H", "2020-01-01T00:00:00", "2019-12-31T23:00:00")]
[InlineData("-PT1M", "2020-01-01T00:00:00", "2019-12-31T23:59:00")]
[InlineData("-PT1S", "2020-01-01T00:00:00", "2019-12-31T23:59:59")]
// Months with different number of days
[InlineData("P1M", "2020-01-31", "2020-02-29")]
[InlineData("P1M", "2020-03-31", "2020-04-30")]
[InlineData("P2M", "2020-01-31", "2020-03-31")]
[InlineData("-P1M", "2020-03-31", "2020-02-29")]
[InlineData("-P1M", "2020-05-31", "2020-04-30")]
[InlineData("-P2M", "2020-03-31", "2020-01-31")]
// Time components
[InlineData("PT1H1M1S", "2020-01-01T00:00:00", "2020-01-01T01:01:01")]
[InlineData("-PT1H1M1S", "2020-01-01T01:01:01", "2020-01-01T00:00:00")]
// Complex durations
[InlineData("P1Y2M3DT4H5M6S", "2020-01-01T00:00:00", "2021-03-04T04:05:06")]
[InlineData("P2Y10M19DT23H59M59S", "2020-01-01T00:00:00", "2022-11-20T23:59:59")]
[InlineData("-P1Y2M3DT4H5M6S", "2021-03-04T04:05:06", "2020-01-01T00:00:00")]
[InlineData("-P2Y10M19DT23H59M59S", "2022-11-20T23:59:59", "2020-01-01T00:00:00")]
// Test the upper bound of DateTime
[InlineData("P9998Y", "0001-01-01", "9999-01-01")]
[InlineData("-P9998Y", "9999-01-01", "0001-01-01")]
// Test the lower bound of DateTime
[InlineData("-P1Y", "0002-01-01", "0001-01-01")]
[InlineData("P1Y", "9998-01-01", "9999-01-01")]
// Test zero-duration scenarios
[InlineData("PT0S", "2020-01-01T00:00:00", "2020-01-01T00:00:00")]
[InlineData("PT0M0S", "2020-01-01T00:00:00", "2020-01-01T00:00:00")]
[InlineData("PT0H0M0S", "2020-01-01T00:00:00", "2020-01-01T00:00:00")]
[InlineData("P0DT0H0M0S", "2020-01-01", "2020-01-01T00:00:00")]
[InlineData("P0W", "2020-01-01", "2020-01-01")]
[InlineData("P0M", "2020-01-01", "2020-01-01")]
[InlineData("P0Y", "2020-01-01", "2020-01-01")]
// Test minimal duration components
[InlineData("P0Y0M0DT0H0M0.1S", "2020-01-01T00:00:00", "2020-01-01T00:00:00.1")]
[InlineData("P0Y0M1DT0H0M0S", "2020-01-01", "2020-01-02")]
[InlineData("P0Y1M0DT0H0M0S", "2020-01-01", "2020-02-01")]
[InlineData("P1Y0M0DT0H0M0S", "2020-01-01", "2021-01-01")]
public void Parse_ValidDurations_ReturnsCorrectTimeSpans(
string iso8601Duration,
string referenceDateString,
string expectedResultString)
{
// Arrange
var referenceDate = DateTime.Parse(referenceDateString);
var expectedResult = DateTime.Parse(expectedResultString);
// Act
var duration = Iso8601DurationParser.ToTimeSpan(iso8601Duration, referenceDate);
// Assert
Assert.Equal(expectedResult, referenceDate + duration);
}
// Test cases for omitted time components
[Theory]
[InlineData("P1Y2M3D", "2020-01-01", "2021-03-04")]
[InlineData("P2Y", "2020-01-01", "2022-01-01")]
[InlineData("P1M1D", "2020-01-01", "2020-02-02")]
public void Parse_OmittedTimeComponents_ReturnsCorrectTimeSpans(
string iso8601Duration,
string referenceDateString,
string expectedResultString)
{
// Arrange
var referenceDate = DateTime.Parse(referenceDateString);
var expectedResult = DateTime.Parse(expectedResultString);
// Act
var duration = Iso8601DurationParser.ToTimeSpan(iso8601Duration, referenceDate);
// Assert
Assert.Equal(expectedResult, referenceDate + duration);
}
// Test cases for leap year scenarios
[Theory]
// Adding 1 year to a leap year date, resulting in the last day of February in a non-leap year
[InlineData("P1Y", "2020-02-29", "2021-02-28")]
// Adding 4 years to a leap year date, which results in another leap year date
[InlineData("P4Y", "2020-02-29", "2024-02-29")]
// Subtracting 1 year from a leap year date, resulting in the last day of February in a non-leap year
[InlineData("-P1Y", "2020-02-29", "2019-02-28")]
// Subtracting 4 years from a leap year date, which results in another leap year date
[InlineData("-P4Y", "2020-02-29", "2016-02-29")]
// Adding a complex duration to a leap year date, resulting in a non-leap year date
[InlineData("P3Y6M4DT12H30M5S", "2020-02-29", "2023-09-01T12:30:05")]
// Adding 2 years to a leap year date, resulting in the last day of February in a non-leap year
[InlineData("P2Y", "2020-02-29", "2022-02-28")]
public void Parse_LeapYearScenarios_ReturnsCorrectTimeSpans(
string iso8601Duration,
string referenceDateString,
string expectedResultString)
{
// Arrange
var referenceDate = DateTime.Parse(referenceDateString);
var expectedResult = DateTime.Parse(expectedResultString);
// Act
var duration = Iso8601DurationParser.ToTimeSpan(iso8601Duration, referenceDate);
// Assert
Assert.Equal(expectedResult, referenceDate + duration);
}
// Test cases for invalid ISO 8601 duration strings
[Theory]
// Incomplete or malformed duration representations
[InlineData("P")] // Missing duration components after "P"
[InlineData("PT")] // Missing time components after "PT"
[InlineData("P1")] // Missing designator after the number
[InlineData("P1Y2M3DT")] // Missing time components after "DT"
// Test invalid duration components
[InlineData("P1Y1M1DT1H1M1S1")] // Extra number "1" after the seconds component
[InlineData("P1Y1M1DT1H1M1S1D")] // Extra "D" after the seconds component
[InlineData("P1Y1M1DT1H1M1S1H")] // Extra "H" after the seconds component
[InlineData("P1Y1M1DT1H1M1S1M")] // Extra "M" after the seconds component
[InlineData("P1Y1M1DT1H1M1S1S")] // Extra "S" after the seconds component
[InlineData("PT1H1M1S1Y")] // "Y" designator after the time components, instead of before the "T" designator
// Test invalid formats
[InlineData("P1Y1M1DT1H1M1S-")] // Extra "-" after the seconds component
[InlineData("P1Y1M1DT1H1M1S+")] // Extra "+" after the seconds component
[InlineData("P1Y1M1DT1H1M1SS")] // Extra "S" after the seconds component
[InlineData("P1Y1M1DT1H1M1MM")] // Extra "M" after the minutes component
[InlineData("P1Y1M1DT1H1M1HH")] // Extra "H" after the hours component
[InlineData("P1Y1M1DT1H1M1DD")] // Extra "D" after the days component
[InlineData("P1Y1M1DT1H1M1YY")] // Extra "Y" after the years component
public void Parse_InvalidDurations_ThrowsArgumentException(string iso8601Duration)
{
var referenceDate = DateTime.Parse("2020-01-01");
Assert.Throws<FormatException>(
() => Iso8601DurationParser.ToTimeSpan(iso8601Duration, referenceDate));
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment