Trening z klawiaturą #2 – Gormanic Calendar

Gormanic Calendar to pomysł, na którym jeden ze swoich popularnych skeczy zbudował angielski komik, Dave Gorman. Pomysł jest prosty, ponieważ jak uważa sam autor, miesiące w roku to jeden wielki bałagan i oszustwem jest, gdy mówi się, że każdy miesiąc to cztery tygodnie. Idąc więc za tym założeniem, jego autorski kalendarz składa się z 13 miesięcy (w tym jeden nowy), każdy po 28 dni, czyli uczciwe cztery tygodnie. W sumie daje nam to… 364 dni w roku. Przypadek? Prawie, dlatego Dave dodał ostatni element czyli tzw. przerwę (ang. intermission). W zależności od tego, czy dany rok jest przestępny czy nie, przerwa ta trwa odpowiednio 1 lub 2 dni. Okazuje się więc, że nowy kalendarz jest zupełnie kompatybilny z naszym obecnym sposobem pomiaru czasu!

To było by jednak za proste, więc Gorman zgłębiając temat, doszukał się informacji, że również same miesiące są źle ułożone w roku… W pewnym momencie istnienia naszej planety, miesiąc lipiec otrzymał nazwę (przynajmniej anglojęzyczną) na cześć Juliusza Cezara (July), to samo spotkało również sierpień (August) na cześć Oktawiana Augusta. To się dopiero nazywa ego…
W każdym razie, przed tymi zmianami pierwszym miesiącem roku był… Marzec. Wówczas w pełni uzasadniony był fakt, że lipiec (noszący wówczas nazwę Quintilis = 5) był piątym miesiącem w roku, a sierpień (Sextilis = 6) odpowiednio szóstym. Dlaczego ktoś to tak później skomplikował? Pozostawiam to do wytłumaczenia naturze ludzkiej. Swoją drogą, poza prostowaniem faktów, skecz Gormana jest po prostu zabawny. Co więcej, daje to pole na… zadanie programistyczne!

Skoro oba systemy są kompatybilne, wręcz na miejscu wydawałoby się mieć konwerter pomiędzy nimi. Tak więc treść tego treningu to napisać funkcję (lub całą klasę), która pozwoli na dokładne konwertowanie daty z kalendarza gregoriańskiego na gormanic i odwrotnie. Testy jednostkowe obowiązkowe!

p.s. Idąc śladem Juliusza Cezara, Dave nazwał 13 miesiąc w roku swoim nazwiskiem (Gormanuary).

– – –

[TestFixture]
    public class GivenAGormanicDate
    {
        [Test]
        public void ThenTheMonthCountIsCorrect()
        {
            Assert.That(GormanicDate.MonthsInyear, Is.EqualTo(13));
        }

        [TestCase(2016,1,1, 2016, "March", 1)]
        [TestCase(2015, 11, 5, 2015, "February", 1)]
        [TestCase(2014, 4, 1, 2014, "June", 7)]
        [TestCase(2014, 3, 2, 2014, "May", 5)]
        public void ThenTheDateIsConvertedCorrectly(int gregorianYear, int gregorianMonth, int gregorianDay, int expectedYear, string expectedMonth, int expectedDay)
        {
            var gregorianDate = new DateTime(gregorianYear, gregorianMonth, gregorianDay, new GregorianCalendar());
            var gormanicDate = new GormanicDate(gregorianDate);

            Assert.That(gormanicDate.Year, Is.EqualTo(gregorianDate.Year));
            Assert.That(gormanicDate.MonthName, Is.EqualTo(expectedMonth));
            Assert.That(gormanicDate.Day, Is.EqualTo(expectedDay));
        }

        [TestCase(2010, 12, 31, true)]
        [TestCase(2016, 12, 30, true)] // leap year I guess
        [TestCase(2010, 12, 30, false)]
        [TestCase(2014, 4, 1, false)]
        public void ThenTheIntermissionIsDetermined(int gregorianYear, int gregorianMonth, int gregorianDay, bool expectIntermission)
        {
            var gregorianDate = new DateTime(gregorianYear, gregorianMonth, gregorianDay, new GregorianCalendar());
            var gormanicDate = new GormanicDate(gregorianDate);

            Assert.That(gormanicDate.IsIntermission, Is.EqualTo(expectIntermission));
        }

        [TestCase(2016, 1, 1, "1st March 2016")]
        [TestCase(2015, 11, 5, "1st February 2015")]
        [TestCase(2014, 4, 1, "7th June 2014")]
        [TestCase(2014, 3, 2, "5th May 2014")]
        public void ThenTheGormanicDateIsCorrectlyRenderedAsString(int gregorianYear, int gregorianMonth, int gregorianDay, string expectedWording)
        {
            var gregorianDate = new DateTime(gregorianYear, gregorianMonth, gregorianDay, new GregorianCalendar());
            var gormanicDate = new GormanicDate(gregorianDate);

            Assert.That(gormanicDate.ToString(), Is.EqualTo(expectedWording));
        }

        [TestCase(2016, 1, 1, true)]
        [TestCase(2016, 1, 30, false)]
        [TestCase(2016, 30, 1, false)]
        [TestCase(2016, 30, 30, false)]
        public void ThenTheGormanicDateIsValidatedCorrectly(int gregorianYear, int gregorianMonth, int gregorianDay, bool isValid)
        {
            Assert.That(GormanicDate.IsValid(gregorianYear, gregorianMonth, gregorianDay), Is.EqualTo(isValid));
        }
    }

Naskrobałem więc powyższe testy. Pierwszy jest dość oczywisty, pilnuje czy moja klasa GormanicDate ma odpowiednią ilość miesięcy w roku. Pozostałe testy sprawdzają, czy różnorodne daty (przypadki graniczne, rok przestępny) są poprawnie konwertowane na drugi z systemów. Pomocny może przydać się istniejący konwerter dostępny online.

To natomiast kod, który zdaje powyższe testy. Oczywiście polecam spróbować samemu i zdawać test po teście, to daje większą satysfakcję!

using System;
using System.Collections.Generic;

namespace GormanianCalendar
{
    public class GormanicDate
    {
        private readonly int _month;

        static readonly List<string> Months = new List<string>{"March", "April", "May", "June", "Quintilis", "Sextilis", "September", "October", "November", "December", "January", "February", "Gormanuary"};
        public static int MonthsInyear => Months.Count;
        private static readonly int DaysinMonth = 28;

        public GormanicDate(DateTime gregorianDate)
        {
            var dayOfTheYear = DayOfTheYear(gregorianDate);
            Year = gregorianDate.Year;
            _month = dayOfTheYear/DaysinMonth;
            Day = dayOfTheYear%DaysinMonth;
        }

        public static int DayOfTheYear(DateTime gregorianDate)
        {
            return gregorianDate.DayOfYear;
        }
        
        public int Year { get; }
        public string MonthName { get { return Months[_month]; } }
        public int Day { get; }

        public object IsIntermission
        {
            get
            {
                var dayInGormanicYear = ( _month*DaysinMonth + Day );
                if (DateTime.IsLeapYear(Year))
                    return dayInGormanicYear >= DaysinMonth*MonthsInyear;
                else
                    return dayInGormanicYear > DaysinMonth * MonthsInyear;
            }
        }

        public override string ToString()
        {
            var dayAsString = Day.ToString();
            var lastChar = dayAsString[dayAsString.Length - 1];

            var stuff = "th";
            if (lastChar == '1')
                stuff = "st";
            else if (lastChar == '2')
                stuff = "nd";
            else if (lastChar == '3')
                stuff = "rd";

            return $"{Day}{stuff} {MonthName} {Year}";
        }

        public static bool IsValid(int gormanicYear, int gormanicMonth, int gormanicDay)
        {
            return ( gormanicMonth < MonthsInyear && gormanicDay <= DaysinMonth );
        }
    }
}

Powyższy kod powinien być jasny, ale w przypadku pytań zapraszam do kontaktu i zostawiania komentarzy. Korzystam tutaj oczywiście z dobrodziejstwa .NET, który ułatwia operowanie na datach. Czy jesteśmy w trakcie Przerwy sprawdzam porównując dzień roku z ilością dni wszystkich miesięcy. Walidacja daty to proste sprawdzanie zakresu liczb.
Obliczenie dnia i miesiąca z daty gregoriańskiej okazuje się bułką z masłem. Dzielimy, bierzemy resztę i po sprawie. To tylko podkreśla jak prosty i dobry jest wymyślony system.
Zabawiłem się jeszcze na koniec w wyświetlanie daty jako tekst, nadpisując metodę toString.

Całość zapakowana w relatywnie schludną klasę, gotową do używania. Pozostaje żałować, że cały ten pomysł to tylko motyw skeczu… 🙂

p.s. 2
Kod źródłowy znajduje się również na github.