From 5d10ed1007771c4d26b470a428eb6dc669c3628e Mon Sep 17 00:00:00 2001 From: Daniel Stolt Date: Wed, 30 Jul 2014 23:17:56 +0200 Subject: [PATCH] Added date/time formatting (the last piece of the puzzle) to DefaultDateFormatter, and unit tests. --- .../Localization/DefaultDateFormatterTests.cs | 144 ++++++++++++++++-- .../Localization/Models/DateTimeParts.cs | 12 ++ .../Services/DefaultDateFormatter.cs | 124 +++++++++------ .../Services/IDateLocalizationServices.txt | 2 +- 4 files changed, 224 insertions(+), 58 deletions(-) diff --git a/src/Orchard.Tests/Localization/DefaultDateFormatterTests.cs b/src/Orchard.Tests/Localization/DefaultDateFormatterTests.cs index 71b2ad780..672833fa0 100644 --- a/src/Orchard.Tests/Localization/DefaultDateFormatterTests.cs +++ b/src/Orchard.Tests/Localization/DefaultDateFormatterTests.cs @@ -44,16 +44,16 @@ namespace Orchard.Framework.Tests.Localization { for (var month = 1; month <= 12; month++) { // All months in the year. DateTime dateTime = new DateTime(1998, month, 1, 10, 30, 30); - + // Print string using Gregorian calendar to avoid calendar conversion. var cultureGregorian = (CultureInfo)culture.Clone(); cultureGregorian.DateTimeFormat.Calendar = cultureGregorian.OptionalCalendars.OfType().First(); var dateTimeString = dateTime.ToString(dateTimeFormat, cultureGregorian); - + var caseKey = String.Format("{0}___{1}___{2}", culture.Name, dateTimeFormat, dateTimeString); allCases.Add(caseKey); //Debug.WriteLine(String.Format("{0} cases tested so far. Testing case {1}...", allCases.Count, caseKey)); - + try { var result = target.ParseDateTime(dateTimeString, dateTimeFormat); var expected = GetExpectedDateTimeParts(dateTime, dateTimeFormat); @@ -90,7 +90,7 @@ namespace Orchard.Framework.Tests.Localization { var target = container.Resolve(); foreach (var dateTimeFormat in formats.AllDateTimeFormats) { // All date and time formats supported by the culture. - foreach (var hour in new [] {0, 6, 12, 18}) { // Enough hours to cover all code paths (AM/PM, 12<->00, etc). + foreach (var hour in new[] { 0, 6, 12, 18 }) { // Enough hours to cover all code paths (AM/PM, 12<->00, etc). DateTime dateTime = new DateTime(1998, 1, 1, hour, 30, 30); @@ -151,16 +151,16 @@ namespace Orchard.Framework.Tests.Localization { for (var month = 1; month <= 12; month++) { // All months in the year. DateTime date = new DateTime(1998, month, 1); - + // Print string using Gregorian calendar to avoid calendar conversion. var cultureGregorian = (CultureInfo)culture.Clone(); cultureGregorian.DateTimeFormat.Calendar = cultureGregorian.OptionalCalendars.OfType().First(); var dateString = date.ToString(dateFormat, cultureGregorian); - + var caseKey = String.Format("{0}___{1}___{2}", culture.Name, dateFormat, dateString); allCases.Add(caseKey); //Debug.WriteLine(String.Format("{0} cases tested so far. Testing case {1}...", allCases.Count, caseKey)); - + try { var result = target.ParseDate(dateString, dateFormat); var expected = GetExpectedDateParts(date, dateFormat); @@ -210,11 +210,11 @@ namespace Orchard.Framework.Tests.Localization { DateTime time = new DateTime(1998, 1, 1, hour, 30, 30); var timeString = time.ToString(timeFormat, culture); - + var caseKey = String.Format("{0}___{1}___{2}", culture.Name, timeFormat, timeString); allCases.Add(caseKey); //Debug.WriteLine(String.Format("{0} cases tested so far. Testing case {1}...", allCases.Count, caseKey)); - + try { var result = target.ParseTime(timeString, timeFormat); var expected = GetExpectedTimeParts(time, timeFormat); @@ -241,6 +241,132 @@ namespace Orchard.Framework.Tests.Localization { target.ParseTime("BlaBlaBla"); } + [Test] + [Description("Date/time formatting works correctly for all combinations of months, format strings and cultures.")] + public void FormatDateTimeTest01() { + var allCases = new ConcurrentBag(); + var failedCases = new ConcurrentDictionary(); + var maxFailedCases = 0; + + var options = new ParallelOptions(); + if (Debugger.IsAttached) { + options.MaxDegreeOfParallelism = 1; + } + + var allCultures = CultureInfo.GetCultures(CultureTypes.AllCultures); + Parallel.ForEach(allCultures, options, culture => { // All cultures on the machine. + var container = InitializeContainer(culture.Name, "GregorianCalendar"); + var formats = container.Resolve(); + var target = container.Resolve(); + + foreach (var dateTimeFormat in formats.AllDateTimeFormats) { // All date/time formats supported by the culture. + for (var month = 1; month <= 12; month++) { // All months in the year. + + DateTime dateTime = new DateTime(1998, month, 1, 10, 30, 30); + DateTimeParts dateTimeParts = new DateTimeParts(1998, month, 1, 10, 30, 30, 0); + + // Print reference string using Gregorian calendar to avoid calendar conversion. + var cultureGregorian = (CultureInfo)culture.Clone(); + cultureGregorian.DateTimeFormat.Calendar = cultureGregorian.OptionalCalendars.OfType().First(); + + var caseKey = String.Format("{0}___{1}___{2}", culture.Name, dateTimeFormat, dateTimeParts); + allCases.Add(caseKey); + //Debug.WriteLine(String.Format("{0} cases tested so far. Testing case {1}...", allCases.Count, caseKey)); + + try { + var result = target.FormatDateTime(dateTimeParts, dateTimeFormat); + var expected = dateTime.ToString(dateTimeFormat, cultureGregorian); + if (result != expected) { + // The .NET date formatting logic contains a bug that causes it to recognize 'd' and 'dd' + // as numerical day specifiers even when they are embedded in literals. Our implementation + // does not contain this bug. If we encounter an unexpected result and the .NET reference + // result contains the genitive month name, replace it with the non-genitive month name + // before asserting. + var numericalDayPattern = @"(\b|[^d])d{1,2}(\b|[^d])"; + var containsNumericalDay = Regex.IsMatch(dateTimeFormat, numericalDayPattern); + if (containsNumericalDay) { + var monthName = formats.MonthNames[month - 1]; + var monthNameGenitive = formats.MonthNamesGenitive[month - 1]; + expected = expected.Replace(monthNameGenitive, monthName); + } + } + Assert.AreEqual(expected, result); + } + catch (Exception ex) { + failedCases.TryAdd(caseKey, ex); + } + } + } + }); + + if (failedCases.Count > maxFailedCases) { + throw new AggregateException(String.Format("Format tests failed for {0} of {1} cases. Expected {2} failed cases or less.", failedCases.Count, allCases.Count, maxFailedCases), failedCases.Values); + } + } + + [Test] + [Description("Date/time formatting works correctly for all combinations of hours, format strings and cultures.")] + public void FormatDateTimeTest02() { + var allCases = new ConcurrentBag(); + var failedCases = new ConcurrentDictionary(); + var maxFailedCases = 0; + + var options = new ParallelOptions(); + if (Debugger.IsAttached) { + options.MaxDegreeOfParallelism = 1; + } + + var allCultures = CultureInfo.GetCultures(CultureTypes.AllCultures); + Parallel.ForEach(allCultures, options, culture => { // All cultures on the machine. + var container = InitializeContainer(culture.Name, "GregorianCalendar"); + var formats = container.Resolve(); + var target = container.Resolve(); + + foreach (var dateTimeFormat in formats.AllDateTimeFormats) { // All date/time formats supported by the culture. + foreach (var hour in new[] { 0, 6, 12, 18 }) { // Enough hours to cover all code paths (AM/PM, 12<->00, 1/2 digits, etc). + + DateTime dateTime = new DateTime(1998, 1, 1, hour, 30, 30); + DateTimeParts dateTimeParts = new DateTimeParts(1998, 1, 1, hour, 30, 30, 0); + + // Print reference string using Gregorian calendar to avoid calendar conversion. + var cultureGregorian = (CultureInfo)culture.Clone(); + cultureGregorian.DateTimeFormat.Calendar = cultureGregorian.OptionalCalendars.OfType().First(); + + var caseKey = String.Format("{0}___{1}___{2}", culture.Name, dateTimeFormat, dateTimeParts); + allCases.Add(caseKey); + //Debug.WriteLine(String.Format("{0} cases tested so far. Testing case {1}...", allCases.Count, caseKey)); + + try { + var result = target.FormatDateTime(dateTimeParts, dateTimeFormat); + var expected = dateTime.ToString(dateTimeFormat, cultureGregorian); + if (result != expected) { + // The .NET date formatting logic contains a bug that causes it to recognize 'd' and 'dd' + // as numerical day specifiers even when they are embedded in literals. Our implementation + // does not contain this bug. If we encounter an unexpected result and the .NET reference + // result contains the genitive month name, replace it with the non-genitive month name + // before asserting. + var numericalDayPattern = @"(\b|[^d])d{1,2}(\b|[^d])"; + var containsNumericalDay = Regex.IsMatch(dateTimeFormat, numericalDayPattern); + if (containsNumericalDay) { + var monthName = formats.MonthNames[0]; + var monthNameGenitive = formats.MonthNamesGenitive[0]; + expected = expected.Replace(monthNameGenitive, monthName); + } + } + Assert.AreEqual(expected, result); + } + catch (Exception ex) { + failedCases.TryAdd(caseKey, ex); + } + } + } + }); + + if (failedCases.Count > maxFailedCases) { + throw new AggregateException(String.Format("Format tests failed for {0} of {1} cases. Expected {2} failed cases or less.", failedCases.Count, allCases.Count, maxFailedCases), failedCases.Values); + } + } + [Test] [Description("Date formatting works correctly for all combinations of months, format strings and cultures.")] public void FormatDateTest01() { diff --git a/src/Orchard/Localization/Models/DateTimeParts.cs b/src/Orchard/Localization/Models/DateTimeParts.cs index 67821ba4b..d1cdf023c 100644 --- a/src/Orchard/Localization/Models/DateTimeParts.cs +++ b/src/Orchard/Localization/Models/DateTimeParts.cs @@ -30,6 +30,18 @@ namespace Orchard.Localization.Models { } } + public DateTime ToDateTime() { + return new DateTime( + Date.Year > 0 ? Date.Year : DateTime.MinValue.Year, + Date.Month > 0 ? Date.Month : DateTime.MinValue.Month, + Date.Day > 0 ? Date.Day : DateTime.MinValue.Day, + Time.Hour > 0 ? Time.Hour : DateTime.MinValue.Hour, + Time.Minute > 0 ? Time.Minute : DateTime.MinValue.Minute, + Time.Second > 0 ? Time.Second : DateTime.MinValue.Second, + Time.Millisecond > 0 ? Time.Millisecond : DateTime.MinValue.Millisecond + ); + } + public override string ToString() { return String.Format("{0} {1}", _date, _time); } diff --git a/src/Orchard/Localization/Services/DefaultDateFormatter.cs b/src/Orchard/Localization/Services/DefaultDateFormatter.cs index a96759710..3f42fdb56 100644 --- a/src/Orchard/Localization/Services/DefaultDateFormatter.cs +++ b/src/Orchard/Localization/Services/DefaultDateFormatter.cs @@ -98,13 +98,25 @@ namespace Orchard.Localization.Services { } public virtual string FormatDateTime(DateTimeParts parts) { - // TODO: Mahsa should implement! - throw new NotImplementedException(); + return FormatDateTime(parts, _dateTimeFormatProvider.ShortDateTimeFormat); } public virtual string FormatDateTime(DateTimeParts parts, string format) { - // TODO: Mahsa should implement! - throw new NotImplementedException(); + var useMonthNameGenitive = GetUseGenitiveMonthName(format); + + var replacements = GetDateFormatReplacements(useMonthNameGenitive).Concat(GetTimeFormatReplacements()).ToDictionary(item => item.Key, item => item.Value); + var formatString = ConvertToFormatString(format, replacements); + var calendar = CurrentCalendar; + + var dateTime = parts.ToDateTime(); + + int twoDigitYear, hour12; + bool isPm; + string monthName, monthNameShort, monthNameGenitive, monthNameShortGenitive, dayName, dayNameShort, amPm, amPmShort, timeZone; + GetDateFormatValues(parts.Date, calendar, dateTime, out twoDigitYear, out monthName, out monthNameShort, out monthNameGenitive, out monthNameShortGenitive, out dayName, out dayNameShort); + GetTimeFormatValues(parts.Time, out isPm, out hour12, out amPm, out amPmShort, out timeZone); + + return String.Format(formatString, parts.Date.Year, twoDigitYear, parts.Date.Month, monthName, monthNameShort, monthNameGenitive, monthNameShortGenitive, parts.Date.Day, dayName, dayNameShort, parts.Time.Hour, hour12, parts.Time.Minute, parts.Time.Second, parts.Time.Millisecond, amPm, amPmShort, timeZone); } public virtual string FormatDate(DateParts parts) { @@ -120,15 +132,10 @@ namespace Orchard.Localization.Services { var dateTime = parts.ToDateTime(); - var yearString = parts.Year.ToString("00", System.Globalization.CultureInfo.InvariantCulture); - var twoDigitYear = Int32.Parse(yearString.Substring(yearString.Length - 2)); - var monthName = parts.Month > 0 ? _dateTimeFormatProvider.MonthNames[parts.Month - 1] : null; - var monthNameShort = parts.Month > 0 ? _dateTimeFormatProvider.MonthNamesShort[parts.Month - 1] : null; - var monthNameGenitive = parts.Month > 0 ? _dateTimeFormatProvider.MonthNamesGenitive[parts.Month - 1] : null; - var monthNameShortGenitive = parts.Month > 0 ? _dateTimeFormatProvider.MonthNamesShortGenitive[parts.Month - 1] : null; - var dayName = parts.Day > 0 ? _dateTimeFormatProvider.DayNames[(int)calendar.GetDayOfWeek(dateTime)] : null; - var dayNameShort = parts.Day > 0 ? _dateTimeFormatProvider.DayNamesShort[(int)calendar.GetDayOfWeek(parts.ToDateTime())] : null; - + int twoDigitYear; + string monthName, monthNameShort, monthNameGenitive, monthNameShortGenitive, dayName, dayNameShort; + GetDateFormatValues(parts, calendar, dateTime, out twoDigitYear, out monthName, out monthNameShort, out monthNameGenitive, out monthNameShortGenitive, out dayName, out dayNameShort); + return String.Format(formatString, parts.Year, twoDigitYear, parts.Month, monthName, monthNameShort, monthNameGenitive, monthNameShortGenitive, parts.Day, dayName, dayNameShort); } @@ -143,11 +150,11 @@ namespace Orchard.Localization.Services { var dateTime = parts.ToDateTime(); bool isPm; - var hour12 = ConvertToHour12(parts.Hour, out isPm); - var amPm = _dateTimeFormatProvider.AmPmDesignators[isPm ? 1 : 0]; - var amPmShort = String.IsNullOrEmpty(amPm) ? "" : amPm[0].ToString(); + int hour12; + string amPm, amPmShort, timeZone; + GetTimeFormatValues(parts, out isPm, out hour12, out amPm, out amPmShort, out timeZone); - return String.Format(formatString, parts.Hour, hour12, parts.Minute, parts.Second, parts.Millisecond, amPm, amPmShort); + return String.Format(formatString, null, null, null, null, null, null, null, null, null, null, parts.Hour, hour12, parts.Minute, parts.Second, parts.Millisecond, amPm, amPmShort, timeZone); } protected virtual DateTimeParts? TryParseDateTime(string dateTimeString, string format, IDictionary replacements) { @@ -253,6 +260,24 @@ namespace Orchard.Localization.Services { return new TimeParts(hour, minute, second, millisecond); } + protected virtual void GetDateFormatValues(DateParts parts, Calendar calendar, DateTime dateTime, out int twoDigitYear, out string monthName, out string monthNameShort, out string monthNameGenitive, out string monthNameShortGenitive, out string dayName, out string dayNameShort) { + var yearString = parts.Year.ToString("00", System.Globalization.CultureInfo.InvariantCulture); + twoDigitYear = Int32.Parse(yearString.Substring(yearString.Length - 2)); + monthName = parts.Month > 0 ? _dateTimeFormatProvider.MonthNames[parts.Month - 1] : null; + monthNameShort = parts.Month > 0 ? _dateTimeFormatProvider.MonthNamesShort[parts.Month - 1] : null; + monthNameGenitive = parts.Month > 0 ? _dateTimeFormatProvider.MonthNamesGenitive[parts.Month - 1] : null; + monthNameShortGenitive = parts.Month > 0 ? _dateTimeFormatProvider.MonthNamesShortGenitive[parts.Month - 1] : null; + dayName = parts.Day > 0 ? _dateTimeFormatProvider.DayNames[(int)calendar.GetDayOfWeek(dateTime)] : null; + dayNameShort = parts.Day > 0 ? _dateTimeFormatProvider.DayNamesShort[(int)calendar.GetDayOfWeek(parts.ToDateTime())] : null; + } + + protected virtual void GetTimeFormatValues(TimeParts parts, out bool isPm, out int hour12, out string amPm, out string amPmShort, out string timeZone) { + hour12 = ConvertToHour12(parts.Hour, out isPm); + amPm = _dateTimeFormatProvider.AmPmDesignators[isPm ? 1 : 0]; + amPmShort = String.IsNullOrEmpty(amPm) ? "" : amPm[0].ToString(); + timeZone = ""; // TODO: Add time zone information to TimeParts and print this out correctly. + } + protected virtual bool GetUseGenitiveMonthName(string format) { // Use genitive month name if the format (excluding literals) contains a numerical day component (d or dd). var formatWithoutLiterals = Regex.Replace(format, @"(? GetTimeParseReplacements() { + var amDesignator = _dateTimeFormatProvider.AmPmDesignators[0]; + var pmDesignator = _dateTimeFormatProvider.AmPmDesignators[1]; return new Dictionary() { {"HH", "(?[0-9]{2})"}, {"H", "(?[0-9]{1,2})"}, @@ -295,22 +322,22 @@ namespace Orchard.Localization.Services { {"fff", "(?[0-9]{3})"}, {"ff", "(?[0-9]{2})"}, {"f", "(?[0-9]{1})"}, - {"tt", String.Format(@"\s*(?{0}|{1})\s*", EscapeForRegex(_dateTimeFormatProvider.AmPmDesignators[0]), EscapeForRegex(_dateTimeFormatProvider.AmPmDesignators[1]))}, - {"t", String.Format(@"\s*(?{0}|{1})\s*", EscapeForRegex(_dateTimeFormatProvider.AmPmDesignators[0][0].ToString()), EscapeForRegex(_dateTimeFormatProvider.AmPmDesignators[1][0].ToString()))}, + {"tt", String.Format(@"\s*(?{0}|{1})\s*", EscapeForRegex(amDesignator), EscapeForRegex(pmDesignator))}, + {"t", String.Format(@"\s*(?{0}|{1})\s*", String.IsNullOrEmpty(amDesignator) ? "" : EscapeForRegex(amDesignator[0].ToString()), String.IsNullOrEmpty(pmDesignator) ? "" : EscapeForRegex(pmDesignator[0].ToString()))}, {"K", @"(?Z|(\+|-)[0-9]{2}:[0-9]{2})*"} }; } protected virtual Dictionary GetDateFormatReplacements(bool useMonthNameGenitive) { return new Dictionary() { - {"dddd", "{8:dddd}"}, - {"ddd", "{9:ddd}"}, + {"dddd", "{8}"}, + {"ddd", "{9}"}, {"dd", "{7:00}"}, {"d", "{7:##}"}, - {"MMMM", useMonthNameGenitive ? "{5:MMMM}" : "{3:MMMM}"}, + {"MMMM", useMonthNameGenitive ? "{5}" : "{3}"}, // The .NET formatting logic never uses the abbreviated genitive month name; doing the same for compatibility. - //{"MMM", useMonthNameGenitive ? "{6:MMM}" : "{4:MMM}"}, - {"MMM", "{4:MMM}"}, + //{"MMM", useMonthNameGenitive ? "{6}" : "{4}"}, + {"MMM", "{4}"}, {"MM", "{2:00}"}, {"M", "{2:##}"}, {"yyyyy", "{0:00000}"}, @@ -323,30 +350,31 @@ namespace Orchard.Localization.Services { protected virtual Dictionary GetTimeFormatReplacements() { return new Dictionary() { - {"HH", "{0:00}"}, - {"H", "{0:#0}"}, - {"hh", "{1:00}"}, - {"h", "{1:#0}"}, - {"mm", "{2:00}"}, - {"m", "{2:#0}"}, - {"ss", "{3:00}"}, - {"s", "{3:#0}"}, - {"fffffff", "{4:0000000}"}, - {"ffffff", "{4:000000}"}, - {"fffff", "{4:00000}"}, - {"ffff", "{4:0000}"}, - {"fff", "{4:000}"}, - {"ff", "{4:00}"}, - {"f", "{4:0}"}, - {"FFFFFFF", "{4:#######}"}, - {"FFFFFF", "{4:######}"}, - {"FFFFF", "{4:#####}"}, - {"FFFF", "{4:####}"}, - {"FFF", "{4:###}"}, - {"FF", "{4:##}"}, - {"F", "{4:#}"}, - {"tt", "{5}"}, - {"t", "{6}"} + {"HH", "{10:00}"}, + {"H", "{10:#0}"}, + {"hh", "{11:00}"}, + {"h", "{11:#0}"}, + {"mm", "{12:00}"}, + {"m", "{12:#0}"}, + {"ss", "{13:00}"}, + {"s", "{13:#0}"}, + {"fffffff", "{14:0000000}"}, + {"ffffff", "{14:000000}"}, + {"fffff", "{14:00000}"}, + {"ffff", "{14:0000}"}, + {"fff", "{14:000}"}, + {"ff", "{14:00}"}, + {"f", "{14:0}"}, + {"FFFFFFF", "{14:#######}"}, + {"FFFFFF", "{14:######}"}, + {"FFFFF", "{14:#####}"}, + {"FFFF", "{14:####}"}, + {"FFF", "{14:###}"}, + {"FF", "{14:##}"}, + {"F", "{14:#}"}, + {"tt", "{15}"}, + {"t", "{16}"}, + {"K", "{17}"} }; } diff --git a/src/Orchard/Localization/Services/IDateLocalizationServices.txt b/src/Orchard/Localization/Services/IDateLocalizationServices.txt index c125ac497..622e1cf55 100644 --- a/src/Orchard/Localization/Services/IDateLocalizationServices.txt +++ b/src/Orchard/Localization/Services/IDateLocalizationServices.txt @@ -41,8 +41,8 @@ struct DateLocalizationOptions { } TODO: - * Implement formatting in addition to parsing * Test for proper handling of fraction (f) format specifier - suspect it does not work properly in current state + * Add formatting and parsing of time zone information (add timezone properties to TimeParts structure) * Rewrite DefaultDateLocalizationServices * Write unit tests for DefaultDateLocalizationServices * Add warning message when saving unsupported combination in settings