Monday, March 3, 2014

A Regular Expression That Parses Leap Years

We recently had some end users that put in some invalid dates that bypassed our standard regular expression for date validation.  Users put in 02/29/2014.  In case you didn't know, 2014 is not a leap year.  I found a great regular expression on RegexLib that handles leap years.  Even though we are talking about client side validation.  It should be said that server date validation should also be performed.


http://www.regexlib.com/REDetails.aspx?regexp_id=279

Here it is:
((^(10|12|0?[13578])([/])(3[01]|[12][0-9]|0?[1-9])([/])((1[8-9]\d{2})|([2-9]\d{3}))$)|(^(11|0?[469])([/])(30|[12][0-9]|0?[1-9])([/])((1[8-9]\d{2})|([2-9]\d{3}))$)|(^(0?2)([/])(2[0-8]|1[0-9]|0?[1-9])([/])((1[8-9]\d{2})|([2-9]\d{3}))$)|(^(0?2)([/])(29)([/])([2468][048]00)$)|(^(0?2)([/])(29)([/])([3579][26]00)$)|(^(0?2)([/])(29)([/])([1][89][0][48])$)|(^(0?2)([/])(29)([/])([2-9][0-9][0][48])$)|(^(0?2)([/])(29)([/])([1][89][2468][048])$)|(^(0?2)([/])(29)([/])([2-9][0-9][2468][048])$)|(^(0?2)([/])(29)([/])([1][89][13579][26])$)|(^(0?2)([/])(29)([/])([2-9][0-9][13579][26])$))

I was skeptical because the regular expression is so long and it is easy to make a mistake with something this complex.  I created some NUnit tests to verify the functionality.  I would have to say that Saurabh Nath did an excellent job.

Here is the set of NUnit tests that I created:

[TestFixture]
public class LeapDateTests
{
    private Regex _dateRegex;

    [TestFixtureSetUp]
    public void Start()
    {
        _dateRegex = new Regex(@"((^(10|12|0?[13578])([/])(3[01]|[12][0-9]|0?[1-9])([/])((1[8-9]\d{2})|([2-9]\d{3}))$)|(^(11|0?[469])([/])(30|[12][0-9]|0?[1-9])([/])((1[8-9]\d{2})|([2-9]\d{3}))$)|(^(0?2)([/])(2[0-8]|1[0-9]|0?[1-9])([/])((1[8-9]\d{2})|([2-9]\d{3}))$)|(^(0?2)([/])(29)([/])([2468][048]00)$)|(^(0?2)([/])(29)([/])([3579][26]00)$)|(^(0?2)([/])(29)([/])([1][89][0][48])$)|(^(0?2)([/])(29)([/])([2-9][0-9][0][48])$)|(^(0?2)([/])(29)([/])([1][89][2468][048])$)|(^(0?2)([/])(29)([/])([2-9][0-9][2468][048])$)|(^(0?2)([/])(29)([/])([1][89][13579][26])$)|(^(0?2)([/])(29)([/])([2-9][0-9][13579][26])$))", RegexOptions.Compiled);
    }

    [Test]
    public void TestDaysInMonthFor2014Positive()
    {
        for (int month = 1; month <= 12; month++)
        {
            for (int day = 1; day <= 31; day++)
            {
                //Thirty days hath September, April, June, and November
                if (month == 9 || month == 4 || month == 6 || month == 11)
                {
                    if (day > 30)
                        continue;                       
                }

                //Except February which has 28
                if (month == 2 && day > 28)
                    continue;

                string dateString = string.Format("{0}/{1}/2014", month, day);

                Assert.IsTrue(_dateRegex.IsMatch(dateString), dateString);
            }
        }
    }

    [Test]
    public void TestDaysInMonthFor2014Negative()
    {
        for (int month = 1; month <= 12; month++)
        {
            for (int day = 1; day <= 31; day++)
            {
                string dateString = string.Format("{0}/{1}/2014", month, day);

                //Thirty days hath September, April, June, and November
                if (month == 9 || month == 4 || month == 6 || month == 11)
                {
                    if (day > 30)
                        Assert.IsFalse(_dateRegex.IsMatch(dateString), dateString);
                }

                //Except February which has 28
                if (month == 2 && day > 28)
                    Assert.IsFalse(_dateRegex.IsMatch(dateString), dateString);
            }
        }
    }

    [Test]
    public void TestLeapYearsPositive()
    {
        for (int year = 3000; year > 1800; year--)
        {
            if (!IsLeapYear(year))
                continue;
           
            //February 29th
            string dateString = string.Format("02/29/{0}", year);
            Assert.IsTrue(_dateRegex.IsMatch(dateString), dateString);

            //February 30th
            dateString = string.Format("02/30/{0}", year);
            Assert.IsFalse(_dateRegex.IsMatch(dateString), dateString);

            //February 30th
            dateString = string.Format("02/31/{0}", year);
            Assert.IsFalse(_dateRegex.IsMatch(dateString), dateString);
        }
    }

    [Test]
    public void TestLeapYearsNegative()
    {
        for (int year = 3000; year > 1800; year--)
        {
            if (IsLeapYear(year))
                continue;

            //February 29th
            string dateString = string.Format("02/29/{0}", year);
            Assert.IsFalse(_dateRegex.IsMatch(dateString), dateString);

            //February 30th
            dateString = string.Format("02/30/{0}", year);
            Assert.IsFalse(_dateRegex.IsMatch(dateString), dateString);

            //February 30th
            dateString = string.Format("02/31/{0}", year);
            Assert.IsFalse(_dateRegex.IsMatch(dateString), dateString);
        }
    }

    private bool IsLeapYear(int year)
    {
        //See:  http://www.timeanddate.com/date/leapyear.html
        bool evenlyDivisibleByFour = year%4 == 0;
        bool evenlyDivisibleByOneHundred = year%100 == 0;
        bool evenlyDivisibleByFourHundred = year%400 == 0;

        if (!evenlyDivisibleByFour)
            return false;

        if (evenlyDivisibleByOneHundred && !evenlyDivisibleByFourHundred)
            return false;

        return true;
    }
}

2 comments:

  1. A little nit to pick; loops in tests are not a good idea, nested loops are a very bad idea. If/when the test fails you have n*n potential places to look. The problem is compounded by not having a failure message in your Assert call. I also think testing every possible condition is a bit overkill, even in this case.

    In my opinion, I think a better solution is to row test the bounds of your data set as well as a few places in the "happy middle" and a few places outside the bounds as a negative test. This provides ample coverage, will make the tests easier to understand and provide better failure data.

    Just my $0.02

    ReplyDelete
  2. What was behind the tests was to verify the regular expression. No one had rated it on RegexLib. This is a one time deal. The date being parsed is shown in the error message.

    ReplyDelete